From cf6af33606f838bc492cd7bba75d1d5c480e1edc Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 12:07:29 -0700 Subject: [PATCH 01/46] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e6ec695aea..07b31b1c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ packages/mobile/certs/public-key.pem .claude/* !.claude/skills/ .context/ +.worktrees/ .DS_Store *.swp *.swo From b5e9cd66ca19f1a7265e5a29394b2aee8396d08e Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 12:56:03 -0700 Subject: [PATCH 02/46] Prevent mobile app test routes --- AGENTS.md | 1 + package.json | 5 +- packages/mobile/.rnstorybook/main.ts | 5 +- packages/mobile/.storybook/main.ts | 2 +- packages/mobile/AGENTS.md | 1 + packages/mobile/README.md | 4 +- .../(tabs)/index.stories.tsx | 2 +- .../(tabs)/strain.stories.tsx | 2 +- .../activities.stories.tsx | 2 +- .../activity/ZoneCharts.stories.tsx | 2 +- .../{app => app-stories}/login.stories.tsx | 2 +- .../providers/index.stories.tsx | 2 +- .../{app => app-stories}/settings.stories.tsx | 2 +- .../(tabs)/_layout.test.tsx | 0 .../{app => app-tests}/(tabs)/food.test.tsx | 2 +- .../{app => app-tests}/(tabs)/index.test.tsx | 16 ++-- .../(tabs)/recovery.test.tsx | 12 +-- .../{app => app-tests}/(tabs)/strain.test.tsx | 12 +-- .../{app => app-tests}/+native-intent.test.ts | 2 +- .../_layout.cleanup.test.tsx | 4 +- .../_layout.telemetry-guard.test.ts | 2 +- .../mobile/{app => app-tests}/_layout.test.ts | 2 +- .../{app => app-tests}/activity/[id].test.tsx | 14 +-- .../daily-heart-rate.test.tsx | 4 +- .../heart-rate-visualization.test.tsx | 22 +++-- .../inertial-measurement-unit.test.tsx | 10 +- .../mobile/{app => app-tests}/login.test.tsx | 2 +- .../{app => app-tests}/preview.test.tsx | 8 +- .../providers/[id].test.tsx | 42 ++++---- .../providers/index.test.tsx | 96 +++++++++---------- .../{app => app-tests}/settings.test.tsx | 26 ++--- packages/mobile/package.json | 2 +- scripts/check-mobile-app-route-files.ts | 39 ++++++++ 33 files changed, 203 insertions(+), 146 deletions(-) rename packages/mobile/{app => app-stories}/(tabs)/index.stories.tsx (98%) rename packages/mobile/{app => app-stories}/(tabs)/strain.stories.tsx (98%) rename packages/mobile/{app => app-stories}/activities.stories.tsx (97%) rename packages/mobile/{app => app-stories}/activity/ZoneCharts.stories.tsx (97%) rename packages/mobile/{app => app-stories}/login.stories.tsx (91%) rename packages/mobile/{app => app-stories}/providers/index.stories.tsx (97%) rename packages/mobile/{app => app-stories}/settings.stories.tsx (98%) rename packages/mobile/{app => app-tests}/(tabs)/_layout.test.tsx (100%) rename packages/mobile/{app => app-tests}/(tabs)/food.test.tsx (97%) rename packages/mobile/{app => app-tests}/(tabs)/index.test.tsx (90%) rename packages/mobile/{app => app-tests}/(tabs)/recovery.test.tsx (93%) rename packages/mobile/{app => app-tests}/(tabs)/strain.test.tsx (90%) rename packages/mobile/{app => app-tests}/+native-intent.test.ts (96%) rename packages/mobile/{app => app-tests}/_layout.cleanup.test.tsx (98%) rename packages/mobile/{app => app-tests}/_layout.telemetry-guard.test.ts (92%) rename packages/mobile/{app => app-tests}/_layout.test.ts (92%) rename packages/mobile/{app => app-tests}/activity/[id].test.tsx (94%) rename packages/mobile/{app => app-tests}/daily-heart-rate.test.tsx (95%) rename packages/mobile/{app => app-tests}/heart-rate-visualization.test.tsx (95%) rename packages/mobile/{app => app-tests}/inertial-measurement-unit.test.tsx (95%) rename packages/mobile/{app => app-tests}/login.test.tsx (99%) rename packages/mobile/{app => app-tests}/preview.test.tsx (86%) rename packages/mobile/{app => app-tests}/providers/[id].test.tsx (91%) rename packages/mobile/{app => app-tests}/providers/index.test.tsx (90%) rename packages/mobile/{app => app-tests}/settings.test.tsx (92%) create mode 100644 scripts/check-mobile-app-route-files.ts diff --git a/AGENTS.md b/AGENTS.md index 95ebcef753..6f7e729a13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **Server-side metric computation**: All metric values must be computed on the server — never derive, aggregate, or transform metric data in web or iOS client code. The API response should contain every value the UI needs to display. Clients are responsible only for rendering (colors, labels, formatting, layout) — not for computing the numbers they display. This prevents inconsistencies when the same metric appears on multiple screens or platforms. If a client is calling a scoring/calculation function on raw data from the API, that calculation belongs in the server router instead. - **Good architecture and modeling**: Actively look for opportunities to decouple code, model real-world concepts as proper classes/types, use common interfaces, and apply SOLID principles with domain-driven design. When you see scattered logic that represents a single concept (e.g., "is this provider connected?"), extract it into a model or interface rather than leaving it inline. Prefer domain-driven abstractions over ad-hoc checks spread across the codebase. Follow SOLID principles: single responsibility (each class/module does one thing), open/closed (extend via composition, not modification), Liskov substitution (subtypes must be substitutable), interface segregation (small, focused interfaces), and dependency inversion (depend on abstractions, not concretions). Prefer composition over inheritance — build complex behavior by combining simple, focused components rather than deep class hierarchies. Use dependency injection, strategy patterns, and mixins instead of base classes. - **Dual-platform parity (web + mobile)**: Every feature, bug fix, and UI change must be implemented on both `packages/web` and `packages/mobile`. When adding a new page, chart, or data view to one platform, implement the equivalent on the other in the same PR. Shared logic lives in domain-specific packages (`@dofek/format`, `@dofek/scoring`, `@dofek/nutrition`, `@dofek/training`, `@dofek/stats`, `@dofek/onboarding`, `@dofek/providers`) — import from there instead of duplicating. Platform-specific code (HealthKit, barcode scanning, Expo secure storage, ECharts vs react-native-svg) stays in the respective package. When reviewing PRs, check that both platforms are updated. +- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Always report errors to Sentry**: Never silently swallow errors or only log them. Every `catch` block that handles an unexpected error must call `captureException()` (from `./telemetry` in mobile, or the equivalent in server code) so failures are visible in our error monitoring. Silent `catch(() => {})` blocks are banned — they hide bugs and make debugging impossible. - **Surface errors to the user by default**: When a server error occurs, send a specific, actionable error message to the client — never hide it behind a generic "Something went wrong" or "Failed to load." Use a TRPCError with an appropriate code (e.g., `PRECONDITION_FAILED`, `NOT_FOUND`) and a human-readable message that tells the user what's wrong and what to do. Clients must display `error.message` from the server, not hardcoded strings. Hiding the real error from the user makes debugging slower and generates support requests that could be self-service. - **Fail fast, never warn-and-continue**: When a required precondition is missing (env file, config, dependency), fail immediately with a clear error — never log a warning and silently continue with broken state. A deploy that proceeds with an empty `.env.prod` is worse than one that fails loudly. Warnings that don't stop execution are deceptive; they hide the real problem and cause confusing downstream failures. diff --git a/package.json b/package.json index 99c8435bdb..61b049e244 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "generate": "drizzle-kit generate && tsx scripts/generate-schema-diagram.ts", "schema:diagram": "tsx scripts/generate-schema-diagram.ts", "schema:view": "tsx scripts/generate-schema-diagram.ts --open", - "lint": "biome check . --max-diagnostics=500", + "lint": "biome check . --max-diagnostics=500 && tsx scripts/check-mobile-app-route-files.ts", "lint:fix": "biome check --write .", "sherif": "sherif", "format": "biome format --write .", @@ -116,7 +116,8 @@ "size": "size-limit", "depcruise": "depcruise --config .dependency-cruiser.cjs src/ packages/", "spellcheck": "cspell --no-progress", - "check:mobile-update": "./scripts/check-mobile-update.sh" + "check:mobile-update": "./scripts/check-mobile-update.sh", + "check:mobile-app-routes": "tsx scripts/check-mobile-app-route-files.ts" }, "dependencies": { "@aws-crypto/client-node": "4.2.2", diff --git a/packages/mobile/.rnstorybook/main.ts b/packages/mobile/.rnstorybook/main.ts index d3aea1eb02..53dfff1746 100644 --- a/packages/mobile/.rnstorybook/main.ts +++ b/packages/mobile/.rnstorybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from "@storybook/react-native"; const config: StorybookConfig = { - stories: ["../components/**/*.stories.?(ts|tsx|js|jsx)", "../app/**/*.stories.?(ts|tsx|js|jsx)"], + stories: [ + "../components/**/*.stories.?(ts|tsx|js|jsx)", + "../app-stories/**/*.stories.?(ts|tsx|js|jsx)", + ], addons: ["@storybook/addon-ondevice-actions", "@storybook/addon-ondevice-controls"], }; diff --git a/packages/mobile/.storybook/main.ts b/packages/mobile/.storybook/main.ts index 356f407470..531ef2d782 100644 --- a/packages/mobile/.storybook/main.ts +++ b/packages/mobile/.storybook/main.ts @@ -5,7 +5,7 @@ import type { StorybookConfig } from "@storybook/react-native-web-vite"; const currentDir = dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { - stories: ["../components/**/*.stories.@(ts|tsx)", "../app/**/*.stories.@(ts|tsx)"], + stories: ["../components/**/*.stories.@(ts|tsx)", "../app-stories/**/*.stories.@(ts|tsx)"], framework: "@storybook/react-native-web-vite", docs: { autodocs: "tag", diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index 8285bd16f4..386f243dc3 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -13,6 +13,7 @@ - **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. +- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Query state handling**: Treat loading, error, and empty as separate UI states. Do not use `query.data ?? []` or similar fallbacks when `query.error` exists. Use `components/QueryStatePanel.tsx` for explicit error/empty/loading states on screens and cards. ### Native Config Consistency diff --git a/packages/mobile/README.md b/packages/mobile/README.md index b6a260288b..69cb5db5ad 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -14,7 +14,9 @@ See `../../docs/nutrition-ai-input.md` for end-to-end behavior and API flow. ## Project Structure -- `app/`: Expo Router screens (file-based routing). +- `app/`: Expo Router screens (file-based routing). Keep this route-only; tests and stories in `app/` can become accidental screens. +- `app-tests/`: Vitest tests for Expo Router screens. +- `app-stories/`: Storybook stories for Expo Router screens. - `components/`: React Native UI components (SVG-based charts). - `modules/`: Native Swift modules: - `background-refresh`: iOS background task registration. diff --git a/packages/mobile/app/(tabs)/index.stories.tsx b/packages/mobile/app-stories/(tabs)/index.stories.tsx similarity index 98% rename from packages/mobile/app/(tabs)/index.stories.tsx rename to packages/mobile/app-stories/(tabs)/index.stories.tsx index 30e4d91142..e9e1c5cd41 100644 --- a/packages/mobile/app/(tabs)/index.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/index.stories.tsx @@ -4,9 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { type ReactNode, useMemo } from "react"; import { View } from "react-native"; +import TodayScreen from "../../app/(tabs)/index"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; -import TodayScreen from "./index"; function localDateString(dayOffset = 0): string { const date = new Date(); diff --git a/packages/mobile/app/(tabs)/strain.stories.tsx b/packages/mobile/app-stories/(tabs)/strain.stories.tsx similarity index 98% rename from packages/mobile/app/(tabs)/strain.stories.tsx rename to packages/mobile/app-stories/(tabs)/strain.stories.tsx index 874a9561cb..70fd8eb779 100644 --- a/packages/mobile/app/(tabs)/strain.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/strain.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import StrainScreen from "./strain"; +import StrainScreen from "../../app/(tabs)/strain"; const mockWorkloadData = { displayedStrain: 12.5, diff --git a/packages/mobile/app/activities.stories.tsx b/packages/mobile/app-stories/activities.stories.tsx similarity index 97% rename from packages/mobile/app/activities.stories.tsx rename to packages/mobile/app-stories/activities.stories.tsx index 22fa3aa5d7..e5cd846c4b 100644 --- a/packages/mobile/app/activities.stories.tsx +++ b/packages/mobile/app-stories/activities.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import ActivitiesScreen from "./activities"; +import ActivitiesScreen from "../app/activities"; function createSeededProviders() { const queryClient = new QueryClient({ diff --git a/packages/mobile/app/activity/ZoneCharts.stories.tsx b/packages/mobile/app-stories/activity/ZoneCharts.stories.tsx similarity index 97% rename from packages/mobile/app/activity/ZoneCharts.stories.tsx rename to packages/mobile/app-stories/activity/ZoneCharts.stories.tsx index 69e239d1fe..ea91e00319 100644 --- a/packages/mobile/app/activity/ZoneCharts.stories.tsx +++ b/packages/mobile/app-stories/activity/ZoneCharts.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { View } from "react-native"; -import { HrZonesChart, PowerZonesChart } from "./[id]"; +import { HrZonesChart, PowerZonesChart } from "../../app/activity/[id]"; const heartRateZones = [ { zone: 1, label: "Recovery", minPct: 50, maxPct: 60, seconds: 300 }, diff --git a/packages/mobile/app/login.stories.tsx b/packages/mobile/app-stories/login.stories.tsx similarity index 91% rename from packages/mobile/app/login.stories.tsx rename to packages/mobile/app-stories/login.stories.tsx index 090324e321..633adf72af 100644 --- a/packages/mobile/app/login.stories.tsx +++ b/packages/mobile/app-stories/login.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { View } from "react-native"; -import LoginScreen from "./login"; +import LoginScreen from "../app/login"; const meta = { title: "Pages/Login", diff --git a/packages/mobile/app/providers/index.stories.tsx b/packages/mobile/app-stories/providers/index.stories.tsx similarity index 97% rename from packages/mobile/app/providers/index.stories.tsx rename to packages/mobile/app-stories/providers/index.stories.tsx index 9f202c2560..3622001267 100644 --- a/packages/mobile/app/providers/index.stories.tsx +++ b/packages/mobile/app-stories/providers/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import type { ComponentType } from "react"; import { View } from "react-native"; +import { ProviderCard } from "../../app/providers/provider-card.tsx"; import { AuthProvider } from "../../lib/auth-context"; -import { ProviderCard } from "./provider-card.tsx"; // ── ProviderCard ── // AuthProvider is resolved to .storybook/mocks/auth-context in web Storybook diff --git a/packages/mobile/app/settings.stories.tsx b/packages/mobile/app-stories/settings.stories.tsx similarity index 98% rename from packages/mobile/app/settings.stories.tsx rename to packages/mobile/app-stories/settings.stories.tsx index 1c97647971..5d8141ba50 100644 --- a/packages/mobile/app/settings.stories.tsx +++ b/packages/mobile/app-stories/settings.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import SettingsScreen from "./settings"; +import SettingsScreen from "../app/settings"; function createSeededProviders() { const queryClient = new QueryClient({ diff --git a/packages/mobile/app/(tabs)/_layout.test.tsx b/packages/mobile/app-tests/(tabs)/_layout.test.tsx similarity index 100% rename from packages/mobile/app/(tabs)/_layout.test.tsx rename to packages/mobile/app-tests/(tabs)/_layout.test.tsx diff --git a/packages/mobile/app/(tabs)/food.test.tsx b/packages/mobile/app-tests/(tabs)/food.test.tsx similarity index 97% rename from packages/mobile/app/(tabs)/food.test.tsx rename to packages/mobile/app-tests/(tabs)/food.test.tsx index 424ffcea2b..51b2039f42 100644 --- a/packages/mobile/app/(tabs)/food.test.tsx +++ b/packages/mobile/app-tests/(tabs)/food.test.tsx @@ -65,7 +65,7 @@ describe("FoodScreen AI meal confirmation", () => { }); it("waits for confirmation before creating AI parsed food entries", async () => { - const { default: FoodScreen } = await import("./food"); + const { default: FoodScreen } = await import("../../app/(tabs)/food"); render(); diff --git a/packages/mobile/app/(tabs)/index.test.tsx b/packages/mobile/app-tests/(tabs)/index.test.tsx similarity index 90% rename from packages/mobile/app/(tabs)/index.test.tsx rename to packages/mobile/app-tests/(tabs)/index.test.tsx index b3fca68b5a..f013891c75 100644 --- a/packages/mobile/app/(tabs)/index.test.tsx +++ b/packages/mobile/app-tests/(tabs)/index.test.tsx @@ -136,7 +136,7 @@ describe("TodayScreen independent loading states", () => { it("shows skeleton placeholder for recovery ring while readiness is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); // In the consolidated query, everything loads together @@ -146,7 +146,7 @@ describe("TodayScreen independent loading states", () => { it("shows skeleton placeholder for strain gauge while workload is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getAllByTestId("skeleton-circle").length).toBeGreaterThanOrEqual(1); @@ -155,7 +155,7 @@ describe("TodayScreen independent loading states", () => { it("hides sleep summary section while sleep analytics is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.queryByText("LAST NIGHT")).toBeNull(); @@ -174,14 +174,14 @@ describe("TodayScreen independent loading states", () => { awakePct: 10, }; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("LAST NIGHT")).toBeTruthy(); }); it("renders all rings when no queries are loading", async () => { - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getAllByText("Recovery").length).toBeGreaterThanOrEqual(1); @@ -193,7 +193,7 @@ describe("TodayScreen independent loading states", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 2, 21, 15, 30)); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); fireEvent.click(screen.getByText("Log Food")); @@ -204,7 +204,7 @@ describe("TodayScreen independent loading states", () => { it("shows a recovery error panel when the readiness query fails", async () => { mockDashboardError = new Error("Dashboard failed"); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("Dashboard failed")).toBeTruthy(); @@ -214,7 +214,7 @@ describe("TodayScreen independent loading states", () => { // In consolidated approach, they share the same error state mockDashboardError = new Error("Dashboard failed"); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("Dashboard failed")).toBeTruthy(); diff --git a/packages/mobile/app/(tabs)/recovery.test.tsx b/packages/mobile/app-tests/(tabs)/recovery.test.tsx similarity index 93% rename from packages/mobile/app/(tabs)/recovery.test.tsx rename to packages/mobile/app-tests/(tabs)/recovery.test.tsx index 0ec01023a1..d7822f5cb6 100644 --- a/packages/mobile/app/(tabs)/recovery.test.tsx +++ b/packages/mobile/app-tests/(tabs)/recovery.test.tsx @@ -95,7 +95,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_spo2: 97 }; mockDailyMetricsData = [{ spo2_avg: 96 }, { spo2_avg: 97 }]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.getByText("Blood Oxygen")).toBeTruthy(); @@ -107,7 +107,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_skin_temp: 36.8 }; mockDailyMetricsData = [{ skin_temp_c: 36.6 }, { skin_temp_c: 36.8 }]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.getByText("Skin Temperature")).toBeTruthy(); @@ -117,7 +117,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_spo2: null }; mockDailyMetricsData = []; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.queryByText("Blood Oxygen")).toBeNull(); @@ -127,7 +127,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_skin_temp: null }; mockDailyMetricsData = []; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.queryByText("Skin Temperature")).toBeNull(); @@ -159,7 +159,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { }, ]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); // Breakdown weight labels should not be visible initially @@ -202,7 +202,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { }, ]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); const readinessSparklineCall = sparkLinePropsCalls.find((sparkLineProps) => { diff --git a/packages/mobile/app/(tabs)/strain.test.tsx b/packages/mobile/app-tests/(tabs)/strain.test.tsx similarity index 90% rename from packages/mobile/app/(tabs)/strain.test.tsx rename to packages/mobile/app-tests/(tabs)/strain.test.tsx index aa35c5fb78..63ea8cea62 100644 --- a/packages/mobile/app/(tabs)/strain.test.tsx +++ b/packages/mobile/app-tests/(tabs)/strain.test.tsx @@ -90,7 +90,7 @@ describe("StrainScreen recent activity navigation", () => { }, ]; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("Morning Ride")); @@ -107,7 +107,7 @@ describe("StrainScreen recent activity navigation", () => { explanation: "Recovery is strong (78). Push for a high-strain day to build fitness.", }; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.getByText("Daily Strain Target")).toBeTruthy(); @@ -119,7 +119,7 @@ describe("StrainScreen recent activity navigation", () => { it("does not render strain target card when no target data", async () => { mockStrainTargetData = undefined; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.queryByText("Daily Strain Target")).toBeNull(); @@ -141,7 +141,7 @@ describe("StrainScreen recent activity navigation", () => { }, ]; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("View all")); @@ -152,7 +152,7 @@ describe("StrainScreen recent activity navigation", () => { it("shows empty state and View all link when no activities exist", async () => { mockActivities = []; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.getByText("Recent Activities")).toBeTruthy(); @@ -163,7 +163,7 @@ describe("StrainScreen recent activity navigation", () => { it("navigates to activities list from View all when no activities exist", async () => { mockActivities = []; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("View all")); diff --git a/packages/mobile/app/+native-intent.test.ts b/packages/mobile/app-tests/+native-intent.test.ts similarity index 96% rename from packages/mobile/app/+native-intent.test.ts rename to packages/mobile/app-tests/+native-intent.test.ts index cc9554fbaa..7a4aed79b2 100644 --- a/packages/mobile/app/+native-intent.test.ts +++ b/packages/mobile/app-tests/+native-intent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { redirectSystemPath } from "./+native-intent"; +import { redirectSystemPath } from "../app/+native-intent"; describe("redirectSystemPath", () => { it("routes shared files to providers import flow", () => { diff --git a/packages/mobile/app/_layout.cleanup.test.tsx b/packages/mobile/app-tests/_layout.cleanup.test.tsx similarity index 98% rename from packages/mobile/app/_layout.cleanup.test.tsx rename to packages/mobile/app-tests/_layout.cleanup.test.tsx index 30c63f95b0..773e174c55 100644 --- a/packages/mobile/app/_layout.cleanup.test.tsx +++ b/packages/mobile/app-tests/_layout.cleanup.test.tsx @@ -108,7 +108,7 @@ vi.mock("../modules/whoop-ble", () => ({ stopImuStreaming: vi.fn(), })); -vi.mock("./login", () => ({ +vi.mock("../app/login", () => ({ default: () => null, })); @@ -127,7 +127,7 @@ mockCreateClient.mockImplementation(() => ({ }, })); -import RootLayout from "./_layout"; +import RootLayout from "../app/_layout"; describe("RootLayout background cleanup", () => { it("tears down background HealthKit sync on unmount", async () => { diff --git a/packages/mobile/app/_layout.telemetry-guard.test.ts b/packages/mobile/app-tests/_layout.telemetry-guard.test.ts similarity index 92% rename from packages/mobile/app/_layout.telemetry-guard.test.ts rename to packages/mobile/app-tests/_layout.telemetry-guard.test.ts index 69d0635b73..dfcb3e81de 100644 --- a/packages/mobile/app/_layout.telemetry-guard.test.ts +++ b/packages/mobile/app-tests/_layout.telemetry-guard.test.ts @@ -20,7 +20,7 @@ describe("app bootstrap telemetry guard", () => { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); - await expect(import("./_layout")).resolves.toBeDefined(); + await expect(import("../app/_layout")).resolves.toBeDefined(); expect(captureExceptionMock).toHaveBeenCalledWith(expect.any(Error), { source: "bootstrap-telemetry-init", }); diff --git a/packages/mobile/app/_layout.test.ts b/packages/mobile/app-tests/_layout.test.ts similarity index 92% rename from packages/mobile/app/_layout.test.ts rename to packages/mobile/app-tests/_layout.test.ts index 68133b16ee..e0294b043d 100644 --- a/packages/mobile/app/_layout.test.ts +++ b/packages/mobile/app-tests/_layout.test.ts @@ -12,7 +12,7 @@ vi.mock("../lib/telemetry", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -import { rootStackScreenOptions } from "./_layout"; +import { rootStackScreenOptions } from "../app/_layout"; describe("rootStackScreenOptions", () => { it("uses a minimal back button so route-group names are never shown", () => { diff --git a/packages/mobile/app/activity/[id].test.tsx b/packages/mobile/app-tests/activity/[id].test.tsx similarity index 94% rename from packages/mobile/app/activity/[id].test.tsx rename to packages/mobile/app-tests/activity/[id].test.tsx index a520a71006..f37916e2b2 100644 --- a/packages/mobile/app/activity/[id].test.tsx +++ b/packages/mobile/app-tests/activity/[id].test.tsx @@ -77,7 +77,7 @@ vi.mock("../../components/RouteMap", () => ({ RouteMap: () => null, })); -vi.mock("./useChartScrub", () => ({ +vi.mock("../../app/activity/useChartScrub", () => ({ useChartScrub: () => ({ touchIndex: null, panResponder: { panHandlers: {} }, @@ -220,13 +220,13 @@ beforeEach(() => { describe("ActivityDetailScreen", () => { it("renders without crashing when stream has heart rate and power data", async () => { - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Morning Ride")).toBeTruthy(); }); it("renders heart rate and power chart labels for cycling with stream data", async () => { - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Heart Rate")).toBeTruthy(); expect(screen.getByText("Power")).toBeTruthy(); @@ -243,7 +243,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Zone 1")).toBeTruthy(); @@ -263,7 +263,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Zone 1")).toBeTruthy(); @@ -292,7 +292,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Yoga Session")).toBeTruthy(); expect(screen.getByText("Heart Rate")).toBeTruthy(); @@ -312,7 +312,7 @@ describe("ActivityDetailScreen", () => { error: null, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText(/Strong \(via Apple Health\)/)).toBeTruthy(); diff --git a/packages/mobile/app/daily-heart-rate.test.tsx b/packages/mobile/app-tests/daily-heart-rate.test.tsx similarity index 95% rename from packages/mobile/app/daily-heart-rate.test.tsx rename to packages/mobile/app-tests/daily-heart-rate.test.tsx index 4caa16ce58..5bd5516695 100644 --- a/packages/mobile/app/daily-heart-rate.test.tsx +++ b/packages/mobile/app-tests/daily-heart-rate.test.tsx @@ -72,13 +72,13 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); describe("DailyHeartRateScreen", () => { it("renders with date navigator and empty state", async () => { - const { default: DailyHeartRateScreen } = await import("./daily-heart-rate"); + const { default: DailyHeartRateScreen } = await import("../app/daily-heart-rate"); render(); diff --git a/packages/mobile/app/heart-rate-visualization.test.tsx b/packages/mobile/app-tests/heart-rate-visualization.test.tsx similarity index 95% rename from packages/mobile/app/heart-rate-visualization.test.tsx rename to packages/mobile/app-tests/heart-rate-visualization.test.tsx index 727f48d16d..bad183cffe 100644 --- a/packages/mobile/app/heart-rate-visualization.test.tsx +++ b/packages/mobile/app-tests/heart-rate-visualization.test.tsx @@ -73,13 +73,15 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); describe("HeartRateVisualizationScreen", () => { it("renders initial state and auto-connects", async () => { - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -91,7 +93,9 @@ describe("HeartRateVisualizationScreen", () => { }); it("shows connecting placeholder before data arrives", async () => { - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -102,7 +106,9 @@ describe("HeartRateVisualizationScreen", () => { const whoopBle = await import("../modules/whoop-ble"); vi.spyOn(whoopBle, "getConnectionState").mockReturnValue("streaming"); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -139,7 +145,9 @@ describe("HeartRateVisualizationScreen", () => { ]; const peekSpy = vi.spyOn(whoopBle, "peekBufferedRealtimeData").mockResolvedValue(samples); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); await act(() => render()); // Flush the async ensureConnected() so startPolling() runs @@ -190,7 +198,9 @@ describe("HeartRateVisualizationScreen", () => { }, ]); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); await act(() => render()); await act(() => vi.advanceTimersByTimeAsync(0)); await act(() => vi.advanceTimersByTimeAsync(1000)); diff --git a/packages/mobile/app/inertial-measurement-unit.test.tsx b/packages/mobile/app-tests/inertial-measurement-unit.test.tsx similarity index 95% rename from packages/mobile/app/inertial-measurement-unit.test.tsx rename to packages/mobile/app-tests/inertial-measurement-unit.test.tsx index 6685ecaba7..1e380a2c36 100644 --- a/packages/mobile/app/inertial-measurement-unit.test.tsx +++ b/packages/mobile/app-tests/inertial-measurement-unit.test.tsx @@ -141,7 +141,7 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); @@ -159,7 +159,7 @@ describe("InertialMeasurementUnitScreen", () => { it("updates permission status when app returns to foreground", async () => { // Start with notDetermined const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); expect(screen.getByText("notDetermined")).toBeTruthy(); @@ -179,7 +179,7 @@ describe("InertialMeasurementUnitScreen", () => { it("requests permission on mount when status is notDetermined", async () => { const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); expect(mockRequestMotionPermission).toHaveBeenCalled(); @@ -192,7 +192,7 @@ describe("InertialMeasurementUnitScreen", () => { mockGetConnectionState.mockReturnValue("scanning"); const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); // Warning should appear in both the error banner and the inline warning @@ -217,7 +217,7 @@ describe("InertialMeasurementUnitScreen", () => { }); const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); // Watch shows "No" for Paired and App Installed diff --git a/packages/mobile/app/login.test.tsx b/packages/mobile/app-tests/login.test.tsx similarity index 99% rename from packages/mobile/app/login.test.tsx rename to packages/mobile/app-tests/login.test.tsx index ea341528ce..9842980df5 100644 --- a/packages/mobile/app/login.test.tsx +++ b/packages/mobile/app-tests/login.test.tsx @@ -37,7 +37,7 @@ vi.mock("../components/ProviderLogo", () => ({ ProviderLogo: () => null, })); -const { default: LoginScreen } = await import("./login"); +const { default: LoginScreen } = await import("../app/login"); describe("LoginScreen", () => { beforeEach(() => { diff --git a/packages/mobile/app/preview.test.tsx b/packages/mobile/app-tests/preview.test.tsx similarity index 86% rename from packages/mobile/app/preview.test.tsx rename to packages/mobile/app-tests/preview.test.tsx index 336a63cd30..12729fbf70 100644 --- a/packages/mobile/app/preview.test.tsx +++ b/packages/mobile/app-tests/preview.test.tsx @@ -33,7 +33,7 @@ describe("PreviewScreen", () => { it("shows loading state with PR number", async () => { mockCheckAndApply.mockImplementation(() => new Promise(() => {})); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -43,7 +43,7 @@ describe("PreviewScreen", () => { it("triggers update check on mount", async () => { mockCheckAndApply.mockResolvedValue({ status: "reloading" }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -58,7 +58,7 @@ describe("PreviewScreen", () => { message: "Network error", }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -70,7 +70,7 @@ describe("PreviewScreen", () => { it("shows no-update message when already up to date", async () => { mockCheckAndApply.mockResolvedValue({ status: "no-update" }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); diff --git a/packages/mobile/app/providers/[id].test.tsx b/packages/mobile/app-tests/providers/[id].test.tsx similarity index 91% rename from packages/mobile/app/providers/[id].test.tsx rename to packages/mobile/app-tests/providers/[id].test.tsx index 5532332c5c..c0d2963353 100644 --- a/packages/mobile/app/providers/[id].test.tsx +++ b/packages/mobile/app-tests/providers/[id].test.tsx @@ -304,7 +304,7 @@ describe("ProviderDetailScreen", () => { describe("Actions", () => { it("renders Sync and Full sync actions for connected providers", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Sync")).toBeTruthy(); @@ -316,7 +316,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Connect")).toBeTruthy(); @@ -328,7 +328,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strong-csv" }); mockProvidersQuery.mockReturnValue({ data: [importOnlyProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Connect")).toBeNull(); @@ -342,7 +342,7 @@ describe("ProviderDetailScreen", () => { isLoading: false, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Re-authorize")).toBeTruthy(); @@ -356,7 +356,7 @@ describe("ProviderDetailScreen", () => { providers: { wahoo: { status: "done", message: "Done" } }, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Sync")); @@ -377,7 +377,7 @@ describe("ProviderDetailScreen", () => { providers: { wahoo: { status: "done", message: "Done" } }, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Full sync")); @@ -394,7 +394,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -416,7 +416,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockSyncHealthKit.mockResolvedValue({ inserted: 12, errors: [] }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -438,7 +438,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockSyncHealthKit.mockResolvedValue({ inserted: 12, errors: [] }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -460,7 +460,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -475,7 +475,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -492,7 +492,7 @@ describe("ProviderDetailScreen", () => { describe("Disconnect", () => { it("renders disconnect button when provider is authorized", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Disconnect Provider")).toBeTruthy(); @@ -502,14 +502,14 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Disconnect Provider")).toBeNull(); }); it("shows Alert.alert with correct title when disconnect button is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -527,7 +527,7 @@ describe("ProviderDetailScreen", () => { it("calls disconnect mutation and navigates back when confirmed", async () => { mockDisconnectMutateAsync.mockResolvedValue({}); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -554,7 +554,7 @@ describe("ProviderDetailScreen", () => { it("invalidates providers and providerStats after successful disconnect", async () => { mockDisconnectMutateAsync.mockResolvedValue({}); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -602,7 +602,7 @@ describe("ProviderDetailScreen", () => { }); it("renders wear location picker when providerId is whoop", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Wear Location")).toBeTruthy(); @@ -612,7 +612,7 @@ describe("ProviderDetailScreen", () => { }); it("renders all five wear location options", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Wrist")).toBeTruthy(); @@ -626,14 +626,14 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "wahoo" }); mockProvidersQuery.mockReturnValue({ data: [authorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Wear Location")).toBeNull(); }); it("calls the settings mutation when a location is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Bicep / Upper Arm")); @@ -645,7 +645,7 @@ describe("ProviderDetailScreen", () => { }); it("optimistically updates the cache when a location is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Chest / Torso")); diff --git a/packages/mobile/app/providers/index.test.tsx b/packages/mobile/app-tests/providers/index.test.tsx similarity index 90% rename from packages/mobile/app/providers/index.test.tsx rename to packages/mobile/app-tests/providers/index.test.tsx index 2502fe60e9..2c8c38cf2f 100644 --- a/packages/mobile/app/providers/index.test.tsx +++ b/packages/mobile/app-tests/providers/index.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { providerActionLabel } from "./provider-card.tsx"; +import { providerActionLabel } from "../../app/providers/provider-card.tsx"; const mockPush = vi.fn(); const mockReplace = vi.fn(); @@ -357,7 +357,7 @@ describe("providerActionLabel", () => { describe("ProviderCard", () => { describe("sync progress", () => { it("renders progress bar when syncing with percentage", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders progress message without percentage", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders progress bar without message when only percentage is provided", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("normal metadata when not syncing", () => { it("renders auth status and last sync time when not syncing", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Never synced' when provider has no lastSyncAt", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders normal metadata when syncing but syncProgress is undefined", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Not connected' status for disconnected providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Expired' status for expired providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("progress percentage clamping", () => { it("renders without error when percentage is negative", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders without error when percentage exceeds 100", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders provider label", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("import-only providers", () => { it("does not render Sync button for import-only providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("does not render Full sync link for import-only providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("shows 'Import only' instead of connection status", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders Full sync link for connected providers", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -675,7 +675,7 @@ describe("ProvidersScreen", () => { error: null, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -690,7 +690,7 @@ describe("ProvidersScreen", () => { error: null, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const stravaCard = within(screen.getByTestId("provider-card-strava")); @@ -698,7 +698,7 @@ describe("ProvidersScreen", () => { }); it("renders Full Sync All button alongside Sync All", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Sync All")).toBeTruthy(); @@ -712,7 +712,7 @@ describe("ProvidersScreen", () => { error: new Error("Providers failed"), }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Providers failed")).toBeTruthy(); @@ -725,7 +725,7 @@ describe("ProvidersScreen", () => { error: new Error("Logs failed"), }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Logs failed")).toBeTruthy(); @@ -738,7 +738,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const wahooCard = within(screen.getByTestId("provider-card-wahoo")); @@ -759,7 +759,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const wahooCard = within(screen.getByTestId("provider-card-wahoo")); @@ -780,7 +780,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); fireEvent.click(screen.getByText("Sync All")); @@ -797,7 +797,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); fireEvent.click(screen.getByText("Full Sync All")); @@ -813,7 +813,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const eightSleepCard = within(screen.getByTestId("provider-card-eight-sleep")); @@ -831,7 +831,7 @@ describe("ProvidersScreen", () => { }); mockCredentialSignIn.mockResolvedValue({}); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Open the modal @@ -863,7 +863,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/Strong%20Export.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -892,7 +892,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/Strong%20Export.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -912,7 +912,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const strongCard = within(screen.getByTestId("provider-card-strong-csv")); @@ -928,7 +928,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Sync All button should not appear when only import-only providers exist @@ -941,7 +941,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/strong.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -981,7 +981,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const polarCard = within(screen.getByTestId("provider-card-polar")); @@ -996,7 +996,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const stravaCard = within(screen.getByTestId("provider-card-strava")); @@ -1023,7 +1023,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const garminCard = within(screen.getByTestId("provider-card-garmin")); @@ -1049,7 +1049,7 @@ describe("ProvidersScreen", () => { }); mockGarminSignIn.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const garminCard = within(screen.getByTestId("provider-card-garmin")); @@ -1086,7 +1086,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const whoopCard = within(screen.getByTestId("provider-card-whoop")); @@ -1116,7 +1116,7 @@ describe("ProvidersScreen", () => { }); mockWhoopSaveTokens.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const whoopCard = within(screen.getByTestId("provider-card-whoop")); @@ -1166,7 +1166,7 @@ describe("ProvidersScreen", () => { }); mockWhoopSaveTokens.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Open modal and sign in @@ -1209,7 +1209,7 @@ describe("ProvidersScreen", () => { mockIsHealthKitAvailable.mockReturnValue(false); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const appleCard = within(screen.getByTestId("provider-card-apple_health")); @@ -1219,7 +1219,7 @@ describe("ProvidersScreen", () => { }); it("renders Apple Health card when HealthKit is available", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByTestId("provider-card-apple_health")).toBeTruthy(); @@ -1227,7 +1227,7 @@ describe("ProvidersScreen", () => { }); it("triggers HealthKit sync with syncRangeDays: 7 when Sync is clicked", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Wait for async permission check to resolve (connected state) @@ -1245,7 +1245,7 @@ describe("ProvidersScreen", () => { }); it("triggers HealthKit sync with syncRangeDays: null when Full sync is clicked", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Wait for async permission check to resolve (connected state) @@ -1267,7 +1267,7 @@ describe("ProvidersScreen", () => { it("shows Connect button when HealthKit was never authorized", async () => { mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1279,7 +1279,7 @@ describe("ProvidersScreen", () => { it("calls requestPermissions when Connect is clicked on Apple Health", async () => { mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1304,7 +1304,7 @@ describe("ProvidersScreen", () => { mockHasEverAuthorized.mockReturnValue(false); mockRequestPermissions.mockRejectedValue(connectError); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1326,7 +1326,7 @@ describe("ProvidersScreen", () => { mockHasEverAuthorized.mockReturnValue(false); mockRequestPermissions.mockRejectedValue(new Error("Authorization denied")); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1352,7 +1352,7 @@ describe("ProvidersScreen", () => { new Error("Missing com.apple.developer.healthkit entitlement."), ); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { diff --git a/packages/mobile/app/settings.test.tsx b/packages/mobile/app-tests/settings.test.tsx similarity index 92% rename from packages/mobile/app/settings.test.tsx rename to packages/mobile/app-tests/settings.test.tsx index 32cea668fd..b5b715fd39 100644 --- a/packages/mobile/app/settings.test.tsx +++ b/packages/mobile/app-tests/settings.test.tsx @@ -157,7 +157,7 @@ beforeEach(() => { describe("SettingsScreen data sources", () => { it("renders Data Sources section with connected count", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -166,7 +166,7 @@ describe("SettingsScreen data sources", () => { }); it("renders provider logos for connected providers only", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -176,7 +176,7 @@ describe("SettingsScreen data sources", () => { }); it("navigates to providers screen when tapped", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -188,7 +188,7 @@ describe("SettingsScreen data sources", () => { describe("SettingsScreen billing", () => { it("renders signup-week limited access notice", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -198,7 +198,7 @@ describe("SettingsScreen billing", () => { }); it("starts checkout when the upgrade button is pressed", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -219,7 +219,7 @@ describe("SettingsScreen billing", () => { canManageBilling: true, }; - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -232,7 +232,7 @@ describe("SettingsScreen billing", () => { describe("SettingsScreen export UI rendering", () => { it("renders the Start Export button", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -245,7 +245,7 @@ describe("SettingsScreen export UI rendering", () => { vi.fn().mockImplementation(() => new Promise(() => {})), ); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -266,7 +266,7 @@ describe("SettingsScreen OTA debug details", () => { const otaCreatedAt = new Date("2026-03-31T18:22:00.000Z"); updatesModule.createdAt = otaCreatedAt; - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -312,7 +312,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -355,7 +355,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -382,7 +382,7 @@ describe("SettingsScreen export flow", () => { }), ); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -433,7 +433,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 05062c166f..adf9d19a3e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -12,7 +12,7 @@ "ios": "expo run:ios", "ios:device": "expo run:ios --device", "prebuild": "expo prebuild --platform ios --clean", - "lint": "biome check .", + "lint": "biome check . && cd ../.. && tsx scripts/check-mobile-app-route-files.ts", "lint:fix": "biome check --write ." }, "dependencies": { diff --git a/scripts/check-mobile-app-route-files.ts b/scripts/check-mobile-app-route-files.ts new file mode 100644 index 0000000000..b043fb2e79 --- /dev/null +++ b/scripts/check-mobile-app-route-files.ts @@ -0,0 +1,39 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; + +const appDirectory = path.resolve("packages/mobile/app"); +const forbiddenRouteFilePattern = /\.(test|stories)\.[jt]sx?$/; + +function findForbiddenFiles(directory: string): string[] { + const entries = readdirSync(directory, { withFileTypes: true }); + const forbiddenFiles: string[] = []; + + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + forbiddenFiles.push(...findForbiddenFiles(absolutePath)); + continue; + } + + if (entry.isFile() && forbiddenRouteFilePattern.test(entry.name)) { + forbiddenFiles.push(path.relative(process.cwd(), absolutePath)); + } + } + + return forbiddenFiles.sort(); +} + +const forbiddenFiles = findForbiddenFiles(appDirectory); + +if (forbiddenFiles.length > 0) { + console.error("Expo Router app directory contains non-route test/story files:"); + for (const file of forbiddenFiles) { + console.error(`- ${file}`); + } + console.error(""); + console.error( + "Move route tests to packages/mobile/app-tests/ and route stories to packages/mobile/app-stories/.", + ); + process.exit(1); +} From 9ebd8c73872e1d2d0ab2edc7ef402ead6e6ba4e4 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 15:38:36 -0700 Subject: [PATCH 03/46] fix: tighten mobile route hygiene guard --- AGENTS.md | 4 ++-- package.json | 2 +- packages/mobile/AGENTS.md | 2 +- packages/mobile/README.md | 3 ++- .../(tabs)/fixture-dates.ts} | 0 .../(tabs)/food-story-fixture.ts} | 0 .../(tabs)/processing-status-story-fixture.ts} | 0 packages/mobile/app-stories/(tabs)/food.stories.tsx | 2 +- packages/mobile/app-stories/(tabs)/index.stories.tsx | 4 ++-- .../mobile/app-stories/(tabs)/recovery.stories.tsx | 6 +++--- .../mobile/app-stories/(tabs)/strain.stories.tsx | 6 +++--- .../mobile/app-tests/(tabs)/_fixture-dates.test.ts | 2 +- .../app-tests/(tabs)/_food-story-fixture.test.ts | 2 +- .../(tabs)/_processing-status-story-fixture.test.ts | 2 +- scripts/check-mobile-app-route-files.ts | 12 ++++++++---- 15 files changed, 26 insertions(+), 21 deletions(-) rename packages/mobile/{app/(tabs)/_fixture-dates.ts => app-fixtures/(tabs)/fixture-dates.ts} (100%) rename packages/mobile/{app/(tabs)/_food-story-fixture.ts => app-fixtures/(tabs)/food-story-fixture.ts} (100%) rename packages/mobile/{app/(tabs)/_processing-status-story-fixture.ts => app-fixtures/(tabs)/processing-status-story-fixture.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index c35b6fd7c9..63c7832d9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **Server-side metric computation**: All metric values must be computed on the server — never derive, aggregate, or transform metric data in web or iOS client code. The API response should contain every value the UI needs to display. Clients are responsible only for rendering (colors, labels, formatting, layout) — not for computing the numbers they display. This prevents inconsistencies when the same metric appears on multiple screens or platforms. If a client is calling a scoring/calculation function on raw data from the API, that calculation belongs in the server router instead. - **Good architecture and modeling**: Actively look for opportunities to decouple code, model real-world concepts as proper classes/types, use common interfaces, and apply SOLID principles with domain-driven design. When you see scattered logic that represents a single concept (e.g., "is this provider connected?"), extract it into a model or interface rather than leaving it inline. Prefer domain-driven abstractions over ad-hoc checks spread across the codebase. Follow SOLID principles: single responsibility (each class/module does one thing), open/closed (extend via composition, not modification), Liskov substitution (subtypes must be substitutable), interface segregation (small, focused interfaces), and dependency inversion (depend on abstractions, not concretions). Prefer composition over inheritance — build complex behavior by combining simple, focused components rather than deep class hierarchies. Use dependency injection, strategy patterns, and mixins instead of base classes. - **Dual-platform parity (web + mobile)**: Every feature, bug fix, and UI change must be implemented on both `packages/web` and `packages/mobile`. When adding a new page, chart, or data view to one platform, implement the equivalent on the other in the same PR. Shared logic lives in domain-specific packages (`@dofek/format`, `@dofek/scoring`, `@dofek/nutrition`, `@dofek/training`, `@dofek/stats`, `@dofek/onboarding`, `@dofek/providers`) — import from there instead of duplicating. Platform-specific code (HealthKit, barcode scanning, Expo secure storage, ECharts vs react-native-svg) stays in the respective package. When reviewing PRs, check that both platforms are updated. -- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. +- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Always report errors to Sentry**: Never silently swallow errors or only log them. Every `catch` block that handles an unexpected error must call `captureException()` (from `./telemetry` in mobile, or the equivalent in server code) so failures are visible in our error monitoring. Silent `catch(() => {})` blocks are banned — they hide bugs and make debugging impossible. - **Surface errors to the user by default**: When a server error occurs, send a specific, actionable error message to the client — never hide it behind a generic "Something went wrong" or "Failed to load." Use a TRPCError with an appropriate code (e.g., `PRECONDITION_FAILED`, `NOT_FOUND`) and a human-readable message that tells the user what's wrong and what to do. Clients must display `error.message` from the server, not hardcoded strings. Hiding the real error from the user makes debugging slower and generates support requests that could be self-service. - **Fail fast, never warn-and-continue**: When a required precondition is missing (env file, config, dependency), fail immediately with a clear error — never log a warning and silently continue with broken state. A deploy that proceeds with an empty `.env.prod` is worse than one that fails loudly. Warnings that don't stop execution are deceptive; they hide the real problem and cause confusing downstream failures. @@ -80,7 +80,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **No exports just for testability**: Never export a function, class, or variable solely because a test needs to access it. Exports define the public API of a module — every export should serve a production consumer. If a test needs to verify internal behavior, test it through the public interface instead. Comments like "exported for testing" are a code smell — if it's worth exporting, it's worth exporting for production use too. - **No test/optional-only branches**: Never write a code branch that is only reachable in tests. If a dependency is always present in production, model it as a required (non-optional) dependency rather than an optional one guarded with `if (!dep) return []` — that guard is dead in production and silently hides infra failures (e.g. returning empty data instead of erroring when ClickHouse is down). Make required deps non-optional (drop the `?`, make constructor/`createApp` params required) and have test helpers always provide a stub (e.g. `makeMockSensorStore`). If a precondition genuinely can fail at runtime, fail loudly with a specific error — never return a benign empty result. - **Shared test utilities**: When multiple unit or integration tests need to share mock setups, utility functions, or test data, extract them into a local `test-helpers.ts` file within the same directory. Do not export these helpers from the source file being tested or import them from another `*.test.ts` file. -- **Colocated unit tests**: Unit test files live next to the source file they test, named `.test.ts`. Do not use `__tests__/` directories. For example, `src/db/tokens.ts` has its unit test at `src/db/tokens.test.ts`. Integration tests (`*.integration.test.ts`) can live wherever makes sense. +- **Colocated unit tests**: Unit test files live next to the source file they test, named `.test.ts`. Do not use `__tests__/` directories. For example, `src/db/tokens.ts` has its unit test at `src/db/tokens.test.ts`. Integration tests (`*.integration.test.ts`) can live wherever makes sense. Mobile Expo Router route tests under `packages/mobile/app-tests/**` are the scoped exception because files under `packages/mobile/app/` are route candidates. - **Test files map 1:1 to source files**: A unit test file should test one source file. If a test file grows too large because it covers several responsibilities, split the production source into smaller SOLID modules/classes and give each source file its own focused colocated test file. Do not split tests by arbitrary scenario while leaving the source file monolithic. - **Test separation**: Unit tests use `*.test.ts`, integration tests use `*.integration.test.ts`. Unit tests must never need access to external services (databases, APIs). Integration tests must never mock at the module level (`vi.mock`). For 3rd party services in integration tests, mock at the network level with [MSW](https://mswjs.io/) (`setupServer` from `msw/node`), not with constructor-injected fetch or `vi.spyOn(globalThis, 'fetch')`. - **Use explicit test tiers**: `pnpm test`, `pnpm test:changed`, and `pnpm test:coverage` are Docker-free unit/mobile tiers. Run database-backed tests through `pnpm test:integration`, `pnpm test:all`, `pnpm test:changed:all`, or `pnpm test:coverage:all`; these commands start the current workspace's Compose dependencies and provide `TEST_DATABASE_URL`. Keep CI's unit, mobile, integration-shard, and mutation commands explicit. See [`docs/testing.md`](docs/testing.md#integration-dependencies) and [Vitest test projects](https://vitest.dev/guide/projects). diff --git a/package.json b/package.json index 0ba957e85f..1e16498ff1 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "depcruise": "depcruise --config .dependency-cruiser.cjs src/ packages/", "spellcheck": "cspell --no-progress", "check:mobile-update": "tsx scripts/check-ota-manifest.ts", - "check:mobile-app-routes": "tsx scripts/check-mobile-app-route-files.ts" + "check:mobile-app-routes": "pnpm tsx scripts/check-mobile-app-route-files.ts" }, "dependencies": { "@ai-sdk/otel": "1.0.47", diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index ec8780e42e..745d2c3648 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -13,7 +13,7 @@ - **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. -- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. +- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Query state handling**: Treat loading, error, and empty as separate UI states. Do not use `query.data ?? []` or similar fallbacks when `query.error` exists. Use `components/QueryStatePanel.tsx` for explicit error/empty/loading states on screens and cards. - **Loading performance**: Follow `../../docs/performance/loading-performance-runbook.md` for slow screens. Do not blank visible previous/cached data during background refetches; use blocking loading only when no usable data exists, preserve server error messages, and keep sync/refresh invalidation targeted to affected query families. diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 16c752c609..ef1a8e9902 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -16,9 +16,10 @@ See `../../docs/nutrition-ai-input.md` for end-to-end behavior and API flow. ## Project Structure -- `app/`: Expo Router screens (file-based routing). Keep this route-only; tests and stories in `app/` can become accidental screens. +- `app/`: Expo Router screens (file-based routing). Keep this route-only; Expo documents `app` as route-exclusive and non-route files there can be treated as routes: . - `app-tests/`: Vitest tests for Expo Router screens. - `app-stories/`: Storybook stories for Expo Router screens. +- `app-fixtures/`: Shared fixtures for route tests and route stories. - `components/`: React Native UI components (SVG-based charts). - `modules/`: Native Swift modules: - `background-refresh`: iOS background task registration. diff --git a/packages/mobile/app/(tabs)/_fixture-dates.ts b/packages/mobile/app-fixtures/(tabs)/fixture-dates.ts similarity index 100% rename from packages/mobile/app/(tabs)/_fixture-dates.ts rename to packages/mobile/app-fixtures/(tabs)/fixture-dates.ts diff --git a/packages/mobile/app/(tabs)/_food-story-fixture.ts b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.ts similarity index 100% rename from packages/mobile/app/(tabs)/_food-story-fixture.ts rename to packages/mobile/app-fixtures/(tabs)/food-story-fixture.ts diff --git a/packages/mobile/app/(tabs)/_processing-status-story-fixture.ts b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts similarity index 100% rename from packages/mobile/app/(tabs)/_processing-status-story-fixture.ts rename to packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts diff --git a/packages/mobile/app-stories/(tabs)/food.stories.tsx b/packages/mobile/app-stories/(tabs)/food.stories.tsx index 23abb92862..158bef393e 100644 --- a/packages/mobile/app-stories/(tabs)/food.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/food.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { View } from "react-native"; -import { seedFoodStoryQuery } from "../../app/(tabs)/_food-story-fixture"; import FoodScreen from "../../app/(tabs)/food"; +import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/index.stories.tsx b/packages/mobile/app-stories/(tabs)/index.stories.tsx index 62f6e3032f..f9da6190ff 100644 --- a/packages/mobile/app-stories/(tabs)/index.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/index.stories.tsx @@ -5,11 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MISSING_PREVIOUS_NIGHT_MESSAGE } from "dofek-server/sleep-need-contract"; import { type ReactNode, useMemo } from "react"; import { View } from "react-native"; +import TodayScreen from "../../app/(tabs)/index"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import TodayScreen from "../../app/(tabs)/index"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/recovery.stories.tsx b/packages/mobile/app-stories/(tabs)/recovery.stories.tsx index c85bf13d3e..cdff69218d 100644 --- a/packages/mobile/app-stories/(tabs)/recovery.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/recovery.stories.tsx @@ -5,12 +5,12 @@ import { mobileRecoveryFixtureSchema } from "dofek-server/mobile-dashboard-contr import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; import { View } from "react-native"; -import { createFixtureDates } from "../../app/(tabs)/_fixture-dates"; +import RecoveryScreen from "../../app/(tabs)/recovery"; +import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import RecoveryScreen from "../../app/(tabs)/recovery"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/strain.stories.tsx b/packages/mobile/app-stories/(tabs)/strain.stories.tsx index 9f9968c20c..6878e64a05 100644 --- a/packages/mobile/app-stories/(tabs)/strain.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/strain.stories.tsx @@ -5,12 +5,12 @@ import { mobileTrainingFixtureSchema } from "dofek-server/mobile-dashboard-contr import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; import { View } from "react-native"; -import { createFixtureDates, type FixtureDates } from "../../app/(tabs)/_fixture-dates"; +import StrainScreen from "../../app/(tabs)/strain"; +import { createFixtureDates, type FixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import StrainScreen from "../../app/(tabs)/strain"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; const STRAIN_COMPANION_RESPONSES = { diff --git a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts b/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts index 135f569ab7..586a1f08c4 100644 --- a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts +++ b/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFixtureDates } from "../../app/(tabs)/_fixture-dates"; +import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; describe("createFixtureDates", () => { afterEach(() => { diff --git a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts b/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts index d5a6d279f2..94b75c9252 100644 --- a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts +++ b/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts @@ -1,6 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { seedFoodStoryQuery } from "../../app/(tabs)/_food-story-fixture"; +import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { FoodByDateV2Schema } from "../../types/api"; describe("seedFoodStoryQuery", () => { diff --git a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts b/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts index bc6b56ebac..1a42044c34 100644 --- a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts +++ b/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; describe("seedReadyProcessingStatus", () => { it("seeds the exact processing status query used by screenshot stories", () => { diff --git a/scripts/check-mobile-app-route-files.ts b/scripts/check-mobile-app-route-files.ts index b043fb2e79..7419445875 100644 --- a/scripts/check-mobile-app-route-files.ts +++ b/scripts/check-mobile-app-route-files.ts @@ -2,7 +2,11 @@ import { readdirSync } from "node:fs"; import path from "node:path"; const appDirectory = path.resolve("packages/mobile/app"); -const forbiddenRouteFilePattern = /\.(test|stories)\.[jt]sx?$/; +const forbiddenRouteFilePatterns = [ + /\.(test|stories)\./, + /(^|[._-])fixtures?([._-]|$)/i, + /(^|[._-])helpers?([._-]|$)/i, +]; function findForbiddenFiles(directory: string): string[] { const entries = readdirSync(directory, { withFileTypes: true }); @@ -16,7 +20,7 @@ function findForbiddenFiles(directory: string): string[] { continue; } - if (entry.isFile() && forbiddenRouteFilePattern.test(entry.name)) { + if (entry.isFile() && forbiddenRouteFilePatterns.some((pattern) => pattern.test(entry.name))) { forbiddenFiles.push(path.relative(process.cwd(), absolutePath)); } } @@ -27,13 +31,13 @@ function findForbiddenFiles(directory: string): string[] { const forbiddenFiles = findForbiddenFiles(appDirectory); if (forbiddenFiles.length > 0) { - console.error("Expo Router app directory contains non-route test/story files:"); + console.error("Expo Router app directory contains non-route files:"); for (const file of forbiddenFiles) { console.error(`- ${file}`); } console.error(""); console.error( - "Move route tests to packages/mobile/app-tests/ and route stories to packages/mobile/app-stories/.", + "Move route tests to packages/mobile/app-tests/, route stories to packages/mobile/app-stories/, and shared fixtures to packages/mobile/app-fixtures/.", ); process.exit(1); } From c4ba57d2930f779c991f3864f7d34592050bcb20 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 15:58:18 -0700 Subject: [PATCH 04/46] fix: handle no-patch audit advisories --- docs/production-incident-baseline.md | 29 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 13 +++++++------ pnpm-workspace.yaml | 6 ++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index a3def01bc7..e5170c0d30 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -23202,3 +23202,32 @@ Drizzle schema and runtime Zod schemas. Findings and remediations: - **Fix / mitigation:** Updated the nine Expo packages to the SDK-compatible patch versions and regenerated the lockfile. Added a scoped `@expo/xcpretty>js-yaml` override to `4.3.1`, leaving unrelated `js-yaml` 3.x and 4.1.x tooling paths unchanged. No audit suppression, retry, timeout, or warning-and-continue behavior was added. - **Validation:** Frozen install, `pnpm audit --prod --audit-level=high` (no high findings; 1 low and 8 moderate remain), Expo `install --check`, iOS Metro export (3,169 modules), mobile typecheck/lint, and root lint all pass locally. - **Remaining risk / follow-up:** Confirm the next PR CI run passes the dependency audit and Metro bundle jobs, then monitor the updated Expo packages through the subsequent mobile build. + +## 2026-08-07 — PR dependency audit hit no-patch image-size advisories + +- **Status:** Fixed in the workspace; the dependency-audit workflow needs a + fresh run from the updated commit. +- **Symptoms / impact:** PR #2446 failed [Test / Dependency Audit](https://github.com/Asherlc/dofek/actions/runs/31224536250/job/93016169639), blocking the PR gate. No production impact was observed. +- **Evidence:** The first fatal command was + `pnpm audit --prod --audit-level=high --ignore-registry-errors`. It reported + high-severity `image-size` advisories + [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) + and + [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq) + through the Expo/Metro mobile build graph, plus + [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) + for `nanoid`. +- **Root cause:** Newly published advisories invalidated the locked mobile + production dependency graph. The `nanoid` path had a compatible patched 3.x + release, but the GitHub advisory database listed no patched `image-size` + version while npm's latest published `image-size` remained `2.0.2`. +- **Fix / mitigation:** Added a scoped `nanoid@<3.3.17` override to `3.3.17` + and regenerated the lockfile. Added only the two no-patch `image-size` GHSA + IDs to `pnpm-workspace.yaml` `audit.ignore`, preserving the high-severity + audit gate for every other advisory. No retry, timeout, or warn-and-continue + behavior was added. +- **Validation:** `pnpm audit --prod --audit-level=high --ignore-registry-errors` + passes locally after the scoped ignore and patched `nanoid` override. +- **Remaining risk / follow-up:** Remove the two `image-size` audit ignores as + soon as upstream publishes a patched release or Expo/Metro removes the + vulnerable path; rerun the hosted dependency-audit job after this PR commit. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 292cd8b17d..f85f550bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,7 @@ overrides: lerna>minimatch: 3.1.4 minimatch@3>brace-expansion: 1.1.18 minimatch: 10.2.5 + nanoid@<3.3.17: 3.3.17 picomatch@>=4.0.0 <4.0.4: 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 @@ -13011,8 +13012,8 @@ packages: nan@2.28.0: resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -18908,7 +18909,7 @@ snapshots: '@gorhom/portal@1.0.14(react-native@0.86.2)(react@19.2.3)': dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -27981,7 +27982,7 @@ snapshots: expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 - nanoid: 3.3.16 + nanoid: 3.3.17 query-string: 7.1.3 react: 19.2.3 react-fast-compare: 3.2.2 @@ -30677,7 +30678,7 @@ snapshots: nan@2.28.0: optional: true - nanoid@3.3.16: {} + nanoid@3.3.17: {} nanospinner@1.2.2: dependencies: @@ -31703,7 +31704,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e024ba50c4..63db18b76c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,11 @@ packages: dedupePeers: true +audit: + ignore: + - GHSA-w3rx-r6r6-pgpr + - GHSA-5p2g-fcmc-qvqq + overrides: "@expo/dom-webview": 55.0.5 "@expo/xcpretty>js-yaml": 4.3.1 @@ -53,6 +58,7 @@ overrides: "lerna>minimatch": 3.1.4 "minimatch@3>brace-expansion": 1.1.18 minimatch: 10.2.5 + "nanoid@<3.3.17": 3.3.17 "picomatch@>=4.0.0 <4.0.4": 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 From 26a5159dcd319a2d741ed67b1f214288e475f544 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 16:00:08 -0700 Subject: [PATCH 05/46] fix: colocate mobile route fixture tests --- packages/mobile/AGENTS.md | 2 +- .../(tabs)/fixture-dates.test.ts} | 2 +- .../(tabs)/food-story-fixture.test.ts} | 2 +- .../(tabs)/processing-status-story-fixture.test.ts} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename packages/mobile/{app-tests/(tabs)/_fixture-dates.test.ts => app-fixtures/(tabs)/fixture-dates.test.ts} (89%) rename packages/mobile/{app-tests/(tabs)/_food-story-fixture.test.ts => app-fixtures/(tabs)/food-story-fixture.test.ts} (89%) rename packages/mobile/{app-tests/(tabs)/_processing-status-story-fixture.test.ts => app-fixtures/(tabs)/processing-status-story-fixture.test.ts} (98%) diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index 745d2c3648..c3118ed471 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -10,7 +10,7 @@ - **Native Modules**: Domain logic for BLE (`WhoopBleModule`) and HealthKit is implemented in Swift. TypeScript only provides the bridge via Expo Modules. ### UI Development -- **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). +- **Storybook**: `.storybook` and `.rnstorybook` contain Storybook configuration. Route stories live in `app-stories/`; component stories live beside their component under `components/`. - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. - **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. diff --git a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts b/packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts similarity index 89% rename from packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts rename to packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts index 586a1f08c4..c08fcc8540 100644 --- a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; +import { createFixtureDates } from "./fixture-dates"; describe("createFixtureDates", () => { afterEach(() => { diff --git a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts similarity index 89% rename from packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts rename to packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts index 94b75c9252..1e409a4615 100644 --- a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts @@ -1,7 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { FoodByDateV2Schema } from "../../types/api"; +import { seedFoodStoryQuery } from "./food-story-fixture"; describe("seedFoodStoryQuery", () => { it("seeds runtime-valid data for the current byDateV2 procedure", () => { diff --git a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts similarity index 98% rename from packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts rename to packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts index 1a42044c34..3f3021f5a6 100644 --- a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; +} from "./processing-status-story-fixture"; describe("seedReadyProcessingStatus", () => { it("seeds the exact processing status query used by screenshot stories", () => { From 1c4e9878763c739d2e270215b6a241b1c74c6810 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 14:42:07 -0700 Subject: [PATCH 06/46] docs: specify processing alert clarity --- ...6-08-08-processing-status-alerts-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-processing-status-alerts-design.md diff --git a/docs/superpowers/specs/2026-08-08-processing-status-alerts-design.md b/docs/superpowers/specs/2026-08-08-processing-status-alerts-design.md new file mode 100644 index 0000000000..be2c0fd919 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-processing-status-alerts-design.md @@ -0,0 +1,189 @@ +# Processing Status Alert Clarity + +## Context + +The Wahoo provider status view currently renders one failed row per dataset in +the failed processing operation. A single provider failure can therefore show +the same generic message many times. The rows also show only the last ready +time, not when the failure occurred, and the current status surface has no +durable way to dismiss an acknowledged failure. + +The processing event stream already contains the operation, dataset, stage, +status, and event timestamps needed to present this information. This design +improves the derived status and alert presentation without changing provider +sync behavior or discarding raw processing events. + +## Goals + +- Show when the current provider failure occurred. +- Show the most recent successful update when one exists. +- Present one alert for one failed provider operation instead of repeating the + same message for every affected dataset. +- Let a user dismiss a specific failure across web and mobile sessions. +- Remove a dismissed or visible failure automatically once a later operation + succeeds for the affected data. +- Keep web, mobile, the provider status view, and the alerts page consistent. + +## Non-goals + +- Changing provider authentication, retry, or sync behavior. +- Deleting or mutating processing operations or stage events. +- Adding client-side freshness or status calculations from raw event data. +- Hiding a new failure merely because an earlier failure was dismissed. +- Adding a general notification-preferences system. + +## Design + +### Durable dismissal state + +Add `fitness.processing_alert_dismissal` in a forward-only Drizzle migration. +The table contains: + +- `user_id`, referencing the owning user profile; +- `operation_id`, referencing the processing operation; +- `dismissed_at`, defaulting to the current timestamp; and +- a primary key on `(user_id, operation_id)`. + +The dismissal is keyed to the failed operation, not a provider name or dataset +label. This means dismissing one Wahoo failure does not suppress a later Wahoo +failure, and the same account-level choice applies to web and mobile. The +table is an explicit relation for event-level state rather than an opaque JSON +entry in the general user-settings key/value store. PostgreSQL foreign keys +provide the ownership and operation relationships used by the existing +processing schema ([PostgreSQL foreign-key documentation](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)). + +The repository will expose a scoped dismissal operation. It must verify that +the requested operation belongs to the authenticated user before inserting a +row. Repeated dismissal requests are idempotent. The tRPC mutation invalidates +the user's processing-status and processing-alert caches after the insert. + +### Server status contract + +Extend the processing status response as follows: + +- Each dataset receives `lastFailedAt`, derived from the newest matching + failed event across the scoped operation history. +- Each operation receives `dismissed`, derived from the dismissal relation. + +The repository remains the source of these derived values. The clients do not +need to inspect event ordering to decide whether a failure is current or when +it occurred. + +The current dataset status continues to come from the newest scoped operation. +Only datasets whose current status is `failed` or `blocked` participate in a +visible failure group. A later ready operation therefore removes the old +failure from the current status response without deleting history. + +### Failure grouping + +The shared provider-status presentation logic groups current failed/blocked +datasets by their current failed operation. A group contains: + +- the operation identifier; +- the provider label; +- the affected dataset labels; +- the failure timestamp; +- the latest successful timestamp among the affected datasets, when present; +- the operation's most relevant error message; and +- whether the operation has been dismissed. + +For a Wahoo operation affecting Activities, Hiking, Cycling, Recovery, +Training, and Data sources, the UI shows one failure card with those areas +listed once. If independent operations are current, they remain separate so +one failure cannot obscure another. + +### Web and mobile presentation + +Both `ProcessingStatusWidget` implementations use the same shared grouping +semantics and display: + +- a provider-level heading such as “Wahoo sync didn’t finish”; +- “Failed: [relative time]”; +- “Last successful update: [relative time]” when available; +- one error message; +- a compact affected-areas list; and +- a clearly labeled dismiss button for the failure group. + +Dismissal is optimistic only through the normal mutation lifecycle: the +control invokes the server mutation, then invalidates the relevant processing +query. While the mutation is pending, the control is disabled. A mutation +error remains visible through the existing server error presentation instead +of silently hiding the failure. + +The existing alerts page and active-alert count consume the same dismissed +operation state. Dismissed current failures are excluded from those surfaces; +new operations still appear normally. The provider detail page continues to +offer its existing sync/reconnect controls. + +## Data flow + +```text +processing events + operation history + | + v + ProcessingRepository.status() + | timestamps + dismissal state + v + tRPC processing.status / alerts + | one group per current failed operation + v + web + mobile widgets/pages + | + v + processing.dismiss(operationId) + | + v +processing_alert_dismissal + cache invalidation +``` + +## Error handling + +- Unknown or user-owned-by-another-user operation IDs fail with a specific + not-found error. +- Database failures from the dismissal mutation propagate to the client and + are reported through the existing server error/telemetry path. +- A status read failure continues to preserve cached status where the existing + widgets already support background-refetch errors. +- Missing timestamps remain explicit: the UI omits the corresponding line + rather than displaying a fabricated age. + +## Testing + +Add tests before implementation for: + +- repository status deriving `lastFailedAt` and operation dismissal state; +- repository dismissal ownership checks and idempotency; +- tRPC dismissal success, cache invalidation, and not-found behavior; +- alert filtering and active-count behavior for dismissed operations; +- shared grouping of several failed datasets into one provider failure; +- failure time and last-success time rendering; +- automatic removal after a later ready operation; +- dismiss controls and mutation error behavior in web and mobile widgets. + +Use executable database integration coverage for the migration/repository +foreign-key behavior where database semantics matter. Keep web/mobile +component tests unit-level and colocated with their source files, following +the repository testing guidance in [`docs/testing.md`](../../../testing.md). + +## Alternatives considered + +### Client-only dismissal + +This would avoid a migration, but dismissal would be device-specific and would +reappear on another client or after clearing local state. It does not meet the +account-wide behavior requested here. + +### JSON dismissal records in `user_settings` + +This would reuse an existing key/value table, but it would encode processing +operation relationships as an opaque document, make cleanup and ownership +constraints weaker, and couple an event-level feature to unrelated settings. +The dedicated relation is clearer and matches the existing relational +processing model. + +## Scope of implementation + +The implementation is limited to the processing schema migration, processing +repository/router contract, shared processing-status presentation helpers, +web/mobile widgets and alert surfaces, and their tests/fixtures/stories. No +provider adapters or sync workers change. From 733a1bcc5eb2c29940fc5f3fed32e1ea195f5fa0 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 14:42:55 -0700 Subject: [PATCH 07/46] docs: specify partial activity totals --- ...ty-overview-partial-measurements-design.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-activity-overview-partial-measurements-design.md diff --git a/docs/superpowers/specs/2026-08-08-activity-overview-partial-measurements-design.md b/docs/superpowers/specs/2026-08-08-activity-overview-partial-measurements-design.md new file mode 100644 index 0000000000..a0be0cb3a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-activity-overview-partial-measurements-design.md @@ -0,0 +1,57 @@ +# Activity Overview Partial Measurements Design + +## Goal + +Show the distance and elevation totals that were recorded for activities even +when other activities in the selected period have no corresponding measurement. +Compare those partial totals when both the current and previous periods contain +at least one measurement. + +## Current behavior + +The activity overview query already uses `sumOrNullIf` for distance and +elevation, so its numeric totals include only non-null measurements. The query +also returns a non-null measurement count for each metric. The repository then +requires that count to equal the activity count before exposing the sum; this +turns a valid partial sum into an unavailable value. + +## Design + +The repository remains the owner of availability and comparison semantics. + +- A distance or elevation total is available when at least one activity in the + period has that measurement. +- The total is the server-computed sum of recorded values only. Missing + activities contribute nothing to the sum. +- A measured numeric zero remains available and is rendered as zero. +- A period with no measurements remains unavailable with the existing + server-authored reason. +- When both periods have available measurements, the comparison is the + difference between their partial totals, regardless of whether either period + has complete measurement coverage. +- If either period has no measurement, the comparison remains unavailable. + +No API fields, database schema, ingestion behavior, read model, or client-side +calculation changes are needed. The existing web and mobile renderers already +display available server-provided values and comparison magnitudes. + +The `sumOrNullIf` behavior relied on here is documented by ClickHouse’s +[`-OrNull` aggregate combinator](https://clickhouse.com/docs/sql-reference/aggregate-functions/combinators#-ornull), +which returns `NULL` when there are no values to aggregate. + +## Testing + +- Update repository unit coverage to prove partial distance and elevation sums + are available and that partial current/previous totals are compared. +- Keep coverage for fully unavailable periods and measured zero values. +- Add web and mobile regression assertions that partial server-provided values + render instead of unavailable copy, including their comparison text. +- Run the focused repository, format, web, and mobile test files, then run + relevant lint/type checks before completion. + +## Scope + +This change is limited to activity overview mapping and its web/mobile +regression coverage. It does not add coverage labels, change activity detail +metrics, alter ingestion or ClickHouse models, or infer values for activities +without measurements. From ace3951609fe89863431cb9bc1d9a17748586971 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 14:46:15 -0700 Subject: [PATCH 08/46] docs: plan partial activity measurements --- ...-activity-overview-partial-measurements.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-activity-overview-partial-measurements.md diff --git a/docs/superpowers/plans/2026-08-08-activity-overview-partial-measurements.md b/docs/superpowers/plans/2026-08-08-activity-overview-partial-measurements.md new file mode 100644 index 0000000000..df726090bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-activity-overview-partial-measurements.md @@ -0,0 +1,274 @@ +# Activity Overview Partial Measurements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose recorded distance and elevation totals for partially measured activity periods and compare those partial totals across periods. + +**Architecture:** Keep the existing ClickHouse aggregate query and API contract. Change the server repository mapper to use the measurement count to distinguish “no values recorded” from “some values recorded,” allowing the existing web and mobile renderers and comparison formatter to display the server-provided partial totals. + +**Tech Stack:** TypeScript, Vitest, Zod-validated ClickHouse repository results, React web, Expo/React Native mobile, pnpm. + +## Global Constraints + +- Metric values and comparison magnitudes remain server-computed; clients only render and unit-format them. +- A total is available when at least one activity has a measurement; missing activities contribute nothing to the total. +- Measured numeric zero remains available and renders as zero. +- A period with no measurements remains unavailable with the existing server-authored reason. +- Partial current and previous totals are compared when both periods have at least one measurement. +- Do not add coverage fields, coverage labels, schema changes, ingestion changes, read-model changes, or client-side aggregation. +- Preserve web/mobile parity by adding equivalent regression coverage for both clients. +- Do not modify unrelated existing work, including the untracked `.nx/` directory. + +--- + +### Task 1: Expose partial totals and compare them in the repository + +**Files:** +- Modify: `packages/server/src/repositories/activities-calendar-repository.test.ts` around the existing partial-overview and comparison cases. +- Modify: `packages/server/src/repositories/activities-calendar-repository.ts` around `overviewMeasurement()` and `overviewPeriodFromRow()`. + +**Interfaces:** +- Consumes: `overviewRowSchema` fields `current_total_distance_meters`, `current_total_elevation_gain_m`, `current_distance_measurement_count`, and `current_elevation_measurement_count`, plus the corresponding previous-period fields. +- Produces: the existing `ActivityOverview` shape with numeric partial totals and `{ status: "available" }` states when measurement count is greater than zero. + +- [ ] **Step 1: Rewrite the existing partial-total test as the failing desired behavior** + +Rename `does not report partial overview totals as available` to +`reports partial overview totals as available`. Keep its two current-period +activities, with distance measured for one activity and elevation measured for +both, then change the expectation to: + +```ts +expect(result).toMatchObject({ + activityCount: 2, + totalDistanceMeters: 5000, + totalDistanceState: { status: "available" }, + totalElevationGainM: 100, + totalElevationState: { status: "available" }, +}); +``` + +Keep the existing previous period empty so the test also proves that a current +partial value does not make an unavailable previous comparison appear +available. + +- [ ] **Step 2: Add a failing partial-to-partial comparison test** + +Add a repository test with two current activities and two previous activities. +Return these overview-row values from the mocked sensor store: + +```ts +{ + current_activity_count: 2, + current_total_minutes: 120, + current_total_distance_meters: 7500, + current_total_elevation_gain_m: 150, + current_distance_measurement_count: 1, + current_elevation_measurement_count: 1, + previous_activity_count: 2, + previous_total_minutes: 90, + previous_total_distance_meters: 5000, + previous_total_elevation_gain_m: 100, + previous_distance_measurement_count: 1, + previous_elevation_measurement_count: 1, +} +``` + +Assert that both period totals are available and that the comparison contains +`totalDistanceMeters: { magnitude: 2500, trend: "higher", state: { status: "available" } }` +and +`totalElevationGainM: { magnitude: 50, trend: "higher", state: { status: "available" } }`. + +- [ ] **Step 3: Run the repository tests and verify the red failure** + +Run: + +```bash +pnpm exec vitest run --project unit packages/server/src/repositories/activities-calendar-repository.test.ts +``` + +Expected: the new partial-total expectation fails because +`overviewPeriodFromRow()` currently nulls any sum whose measurement count is +less than the activity count. The partial-to-partial comparison also reports +an unavailable state for the same reason. + +- [ ] **Step 4: Implement the minimal availability mapping** + +In `overviewPeriodFromRow()`, replace the complete-coverage checks with +measurement-presence checks: + +```ts +const distanceHasMeasurement = distanceMeasurementCount > 0; +const elevationHasMeasurement = elevationMeasurementCount > 0; + +return { + activityCount, + totalMinutes, + totalDistance: overviewMeasurement( + "Distance", + distanceHasMeasurement ? roundNullableMetric(totalDistanceMeters) : null, + distanceHasMeasurement, + ), + totalElevation: overviewMeasurement( + "Elevation gain", + elevationHasMeasurement ? roundNullableMetric(totalElevationGainM) : null, + elevationHasMeasurement, + ), +}; +``` + +Keep `createMeasurementChange()` unchanged: once both period measurements are +available, its existing subtraction and trend logic compares the partial +totals. Preserve the existing unavailable wording for periods with zero +measurements and preserve the separate measured-zero test. + +- [ ] **Step 5: Run the repository tests and verify green** + +Run the same focused Vitest command from Step 3. Expected: all tests in +`activities-calendar-repository.test.ts` pass, including unavailable periods, +measured zeros, complete totals, partial totals, and partial comparisons. + +- [ ] **Step 6: Commit the server behavior and tests** + +```bash +git add packages/server/src/repositories/activities-calendar-repository.ts packages/server/src/repositories/activities-calendar-repository.test.ts +git commit -m "fix: compare partial activity measurements" +``` + +### Task 2: Add web and mobile rendering parity coverage + +**Files:** +- Modify: `packages/web/src/pages/ActivitiesPage.test.tsx` next to the existing unavailable-versus-zero overview test. +- Modify: `packages/mobile/app-tests/(tabs)/activities.test.tsx` next to the equivalent unavailable-versus-zero overview test. +- No production client files are expected to change; both clients already render available server-provided metric values and comparison magnitudes. + +**Interfaces:** +- Consumes: the existing `ActivityOverviewData` contract with numeric totals, `{ status: "available" }` states, and `ActivityOverviewComparison` values. +- Produces: equivalent web and mobile regression coverage proving partial server output is visible to users. + +- [ ] **Step 1: Add the web contract test fixture** + +Add a test that sets `mockOverviewQuery.data` to a partially measured server +response: + +```ts +{ + activityCount: 4, + totalMinutes: 280, + totalDistanceMeters: 12500, + totalDistanceState: { status: "available" }, + totalElevationGainM: 180, + totalElevationState: { status: "available" }, + activityTypes: ["running", "cycling"], + comparison: { + periodLabel: "previous 4 weeks", + activityCount: { magnitude: 1, trend: "higher" }, + totalMinutes: { magnitude: 60, trend: "higher" }, + totalDistanceMeters: { magnitude: 2500, trend: "higher", state: { status: "available" } }, + totalElevationGainM: { magnitude: 50, trend: "higher", state: { status: "available" } }, + }, +} +``` + +Render `ActivitiesPage` and assert `12.5 km`, `180 m`, `2.5 km more vs previous 4 weeks`, +and `50 m more vs previous 4 weeks`. Assert the unavailable copy is absent. +This test validates that the existing web renderer accepts and displays the +partial server contract. + +- [ ] **Step 2: Run the focused web test** + +Run: + +```bash +pnpm exec vitest run --project unit packages/web/src/pages/ActivitiesPage.test.tsx +``` + +Expected: the web test passes after Task 1 because the renderer already +formats available values and comparison magnitudes; if it fails, fix only the +test fixture or the existing rendering contract that prevents server-provided +values from appearing. + +- [ ] **Step 3: Add the equivalent mobile test fixture** + +Add the same partial overview values and comparison values to the mobile +overview test, render `ActivitiesScreen`, and assert `12.5 km`, `180 m`, +`2.5 km more vs previous 4 weeks`, and `50 m more vs previous 4 weeks`. Assert +the unavailable copy is absent. Keep the assertions equivalent to the web +test while using the mobile testing-library queries already established in the +file. + +- [ ] **Step 4: Run the focused mobile test** + +Run: + +```bash +pnpm exec vitest run --project mobile 'packages/mobile/app-tests/(tabs)/activities.test.tsx' +``` + +Expected: the mobile test passes with the existing renderer and confirms +web/mobile parity for partial values and comparisons. + +- [ ] **Step 5: Commit the parity coverage** + +```bash +git add packages/web/src/pages/ActivitiesPage.test.tsx packages/mobile/app-tests/\(tabs\)/activities.test.tsx +git commit -m "test: cover partial activity overview metrics" +``` + +### Task 3: Run final focused verification and review the diff + +**Files:** +- Review only: the two committed implementation/test changes and the approved design/plan documents. + +**Interfaces:** +- Consumes: the repository behavior and web/mobile regression coverage from Tasks 1–2. +- Produces: verified source and test changes with no unrelated modifications. + +- [ ] **Step 1: Run all focused unit tests together** + +Run: + +```bash +pnpm exec vitest run --project unit \ + packages/server/src/repositories/activities-calendar-repository.test.ts \ + packages/format/src/activity-overview.test.ts \ + packages/web/src/pages/ActivitiesPage.test.tsx +pnpm exec vitest run --project mobile 'packages/mobile/app-tests/(tabs)/activities.test.tsx' +``` + +Expected: both commands exit successfully with zero failed tests. + +- [ ] **Step 2: Run type checking and lint on changed packages** + +Run: + +```bash +pnpm typecheck +pnpm lint +``` + +Expected: both commands exit successfully without modifying thresholds, +disabling rules, or adding ignores. + +- [ ] **Step 3: Review the final diff and status** + +Run: + +```bash +git diff HEAD~2..HEAD --check +git diff HEAD~2..HEAD --stat +git status --short +``` + +Confirm the changed source and test files are limited to the repository mapping +and web/mobile parity coverage. Review the already committed design and plan +documents separately. The unrelated untracked `.nx/` directory must remain +untouched. + +- [ ] **Step 4: Complete the retrospective handoff** + +Report the one-sentence root cause, the direct mapping fix, the focused test +commands and results, and whether full typecheck/lint passed. Mention that no +resilience knob, schema change, or backfill was needed. Suggest any useful +future `AGENTS.md`, `README.md`, or runbook wording changes separately rather +than adding unrelated documentation to this change. From 74a239de75b776f6765595dd09350e5f4f56eb23 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 14:47:34 -0700 Subject: [PATCH 09/46] docs: plan processing alert clarity --- .../2026-08-08-processing-status-alerts.md | 474 ++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-processing-status-alerts.md diff --git a/docs/superpowers/plans/2026-08-08-processing-status-alerts.md b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md new file mode 100644 index 0000000000..24c7e9da77 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md @@ -0,0 +1,474 @@ +# Processing Status Alert Clarity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make provider sync failures actionable and non-duplicative by showing failure and last-success times, grouping one failed operation into one alert, and supporting durable account-wide dismissal on web and mobile. + +**Architecture:** Keep processing events and operation history as the canonical source of truth. Add a relational dismissal record keyed by authenticated user and processing operation; have `ProcessingRepository` derive dismissal/failure timestamps and expose one grouped alert per current failed operation. Put grouping rules in `@dofek/providers` metadata so web and mobile render the same semantics, while each platform owns its mutation wiring and native presentation. + +**Tech Stack:** PostgreSQL/Drizzle migrations, TypeScript, tRPC, React, React Native/Expo, `@dofek/providers`, Vitest, Testing Library, Docker-backed integration tests. + +## Global Constraints + +- Write tests first for every changed behavior; use real database integration tests when behavior depends on SQL, constraints, or migration semantics. +- Keep all metric/status values server-derived; clients only group/render the server-provided processing contract and must not infer timestamps from raw events. +- Maintain web/mobile parity for every status and alert behavior. +- Keep `packages/mobile/app/` route-only; put route tests under `packages/mobile/app-tests/`. +- Use a forward-only Postgres migration and update the Drizzle schema metadata through the repository's normal migration workflow. +- Do not modify provider adapters, sync workers, retry behavior, or raw processing events. +- Do not add dependencies or environment variables. +- Preserve the pre-existing untracked `.nx/plans/` worktree content; stage only files belonging to this feature. + +## File map + +- `src/db/schema/processing.ts`: Drizzle declaration for the dismissal relation. +- `drizzle/0071_processing_alert_dismissal.sql` and `drizzle/meta/*`: forward migration and generated schema metadata. +- `src/db/processing-alert-dismissals.integration.test.ts`: executable PostgreSQL coverage for the new relation's ownership and idempotency behavior. +- `packages/server/src/repositories/processing-repository.ts`: derive `lastFailedAt`/`errorMessage`, read dismissals, group current failures for alerts, and persist scoped dismissals. +- `packages/server/src/repositories/processing-repository.test.ts`: repository unit coverage for timestamps, grouping, dismissal state, ownership, and idempotency. +- `packages/server/src/routers/processing.ts`: validate the new response fields and expose `processing.dismiss`. +- `packages/server/src/routers/processing.test.ts`: tRPC contract, mutation, error, and cache invalidation coverage. +- `packages/providers-meta/src/processing-status.ts`: shared status grouping and failure presentation helpers that consume server-derived timestamps/messages. +- `packages/providers-meta/src/processing-status.test.ts`: shared grouping/resolution tests. +- `packages/providers-meta/src/processing-alerts.ts`: grouped alert contract and presentation data. +- `packages/providers-meta/src/processing-alerts.test.ts`: grouped-alert contract tests if new helpers are added there. +- `packages/web/src/components/ProcessingStatusWidget.tsx` and `.test.tsx`: provider/dashboard failure grouping, timestamps, dismissal control, and mutation errors. +- `packages/web/src/pages/AlertsPage.tsx` and `.test.tsx`: grouped alert cards and dismiss action. +- `packages/web/src/components/ProcessingStatusWidget.stories.tsx` and `packages/web/src/pages/AlertsPage.stories.tsx`: current grouped/dismissible failure fixtures. +- `packages/mobile/components/ProcessingStatusWidget.tsx` and `.test.tsx`: mobile-equivalent status behavior. +- `packages/mobile/app/alerts.tsx` and `packages/mobile/app-tests/alerts.test.tsx`: mobile alert dismissal behavior; route source remains under `app/`, tests remain outside it. +- Existing processing-status story fixtures and provider route fixtures under `packages/mobile/app-fixtures/`, `packages/mobile/app-stories/`, and `packages/mobile/app-tests/`: update response shapes with `lastFailedAt` and `dismissed` where TypeScript requires it. + +--- + +### Task 1: Add the processing alert dismissal relation + +**Files:** +- Create: `src/db/processing-alert-dismissals.integration.test.ts` +- Modify: `src/db/schema/processing.ts` +- Create: `drizzle/0071_processing_alert_dismissal.sql` through the normal migration generator +- Modify: `drizzle/meta/_journal.json` and the generated schema snapshot if the generator updates them + +**Interfaces:** +- Produces the `processingAlertDismissal` Drizzle table and the database relation used by the repository in Task 2. +- Table columns: `userId: uuid`, `operationId: uuid`, `dismissedAt: timestamptz`. +- Constraints: primary key `(user_id, operation_id)`, foreign keys to `fitness.user_profile(id)` and `fitness.processing_operation(id)`, both `ON DELETE CASCADE`, and `dismissed_at DEFAULT now()`. + +- [ ] **Step 1: Write the failing integration test** + +Add a Docker-backed test that creates two fixture users and one processing operation for the first user, inserts a dismissal for that operation with a direct SQL helper, verifies the row can be read, verifies the table's duplicate primary key is enforced, and verifies a dismissal for the second user cannot reference the first user's operation through the foreign key relationship. + +The database assertion must exercise the actual foreign keys rather than checking SQL text: + +```ts +const insertDismissal = (userId: string, operationId: string) => database.execute(sql` + INSERT INTO fitness.processing_alert_dismissal (user_id, operation_id) + VALUES (${userId}::uuid, ${operationId}::uuid) + RETURNING user_id, operation_id, dismissed_at +`); + +await expect(insertDismissal(firstUserId, operationId)).resolves.toHaveLength(1); +await expect(insertDismissal(firstUserId, operationId)).rejects.toThrow(); +await expect(insertDismissal(secondUserId, operationId)).rejects.toThrow(); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm test:integration -- src/db/processing-alert-dismissals.integration.test.ts` + +Expected: FAIL because the migration/table and schema declaration do not exist yet. + +- [ ] **Step 3: Add the schema and migration** + +Declare the table beside the other processing tables in `src/db/schema/processing.ts` and export it through the existing Drizzle schema aggregation if required. Generate the next forward migration with the repository's configured Drizzle workflow, keeping the SQL transaction-safe and free of data backfills: + +```sql +CREATE TABLE fitness.processing_alert_dismissal ( + user_id uuid NOT NULL REFERENCES fitness.user_profile (id) ON DELETE CASCADE, + operation_id uuid NOT NULL REFERENCES fitness.processing_operation (id) ON DELETE CASCADE, + dismissed_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT processing_alert_dismissal_pkey PRIMARY KEY (user_id, operation_id) +); +``` + +Update generated Drizzle metadata through the normal command, then run the migration policy check on the new SQL. + +- [ ] **Step 4: Run the integration test to verify it passes** + +Run: `pnpm test:integration -- src/db/processing-alert-dismissals.integration.test.ts` + +Expected: PASS, including the real foreign-key and duplicate-key behavior. + +- [ ] **Step 5: Commit** + +```bash +git add src/db/schema/processing.ts src/db/processing-alert-dismissals.integration.test.ts drizzle/0071_processing_alert_dismissal.sql drizzle/meta +git commit -m "feat: add processing alert dismissals" +``` + +--- + +### Task 2: Derive failure timestamps and durable dismissal state in the repository + +**Files:** +- Modify: `packages/server/src/repositories/processing-repository.ts` +- Modify: `packages/server/src/repositories/processing-repository.test.ts` + +**Interfaces:** +- `ProcessingStatusDataset` gains `lastFailedAt: string | null`. +- `ProcessingStatusOperation` gains `dismissed: boolean` and `errorMessage: string | null`, with the latter derived server-side from the most relevant failed event. +- `ProcessingRepository.dismiss(operationId: string): Promise<{ dismissed: true }>` inserts an idempotent dismissal only for an operation owned by `#userId`; an unknown or foreign operation throws a not-found error. +- `ProcessingRepository.status()` reads dismissal rows only for the scoped operations and returns dismissal state on each operation. +- `ProcessingRepository.alerts()` returns at most one grouped `ProcessingAlert` per current failed/blocked operation and excludes dismissed operations. + +- [ ] **Step 1: Write failing repository tests** + +Extend the existing fixtures and add tests for: + +1. A failed event produces `lastFailedAt` from the event's `occurredAt`, not from `createdAt` or `lastAdvancedAt`. +2. A later ready operation leaves the dataset status ready and prevents the old failure from producing an alert. +3. Several failed datasets in one provider operation produce one alert whose `datasetKeys` contains all affected keys and whose `occurredAt` is the newest matching failure event. +4. A dismissal row marks the corresponding status operation as `dismissed: true` and removes it from `alerts()`. +5. `dismiss(operationId)` inserts once and succeeds again without duplicating the row. +6. `dismiss(operationId)` rejects an unknown or foreign operation with a specific not-found error. + +Use a mocked event store as the current repository tests do, and mock only the database calls needed for dismissal lookup/insert. Assert the public repository result rather than private helpers: + +```ts +expect(result.datasets[0]?.lastFailedAt).toBe("2026-07-22T16:00:00.000Z"); +expect(result.operations[0]?.dismissed).toBe(true); +expect(alerts.alerts).toHaveLength(1); +expect(alerts.alerts[0]?.datasetKeys).toEqual(["activity", "recovery", "sleep"]); +``` + +- [ ] **Step 2: Run the focused repository tests to verify they fail** + +Run: `pnpm test -- packages/server/src/repositories/processing-repository.test.ts` + +Expected: FAIL because the result types and dismissal/grouping behavior are not implemented. + +- [ ] **Step 3: Implement the smallest repository change** + +Add typed row parsing for dismissal lookup. Load all dismissal operation IDs for the authenticated user and scoped operation IDs in one query. While mapping datasets, scan the already-loaded operation events for the newest failed event matching the dataset key (including operation-wide `datasetKey === null` failures) and serialize it as `lastFailedAt`. While mapping operations, derive one `errorMessage` from the most relevant failed event so clients never need to inspect the raw timeline to produce user-facing copy. + +When mapping operations, attach `dismissed: dismissedOperationIds.has(operation.id)`. In `alerts()`, select current failed/blocked datasets, group them by their current operation ID, choose the newest matching failed event for `occurredAt`, combine the dataset keys/labels, use the existing action selection (`reconnect`, `retry_sync`, etc.), and omit dismissed groups. + +Implement dismissal as an ownership-scoped insert/select. The query must not accept a provider or dataset label as identity: + +```ts +INSERT INTO fitness.processing_alert_dismissal (user_id, operation_id) +SELECT ${this.#userId}::uuid, operation.id +FROM fitness.processing_operation operation +WHERE operation.id = ${operationId}::uuid + AND operation.user_id = ${this.#userId}::uuid +ON CONFLICT (user_id, operation_id) DO NOTHING +RETURNING operation_id; +``` + +If no row is returned and the operation is not already dismissed for this user, throw the repository's specific not-found error. Keep unexpected database errors uncaught so the server telemetry/error boundary sees them. + +- [ ] **Step 4: Run focused repository tests to verify they pass** + +Run: `pnpm test -- packages/server/src/repositories/processing-repository.test.ts` + +Expected: PASS, including all existing status/history/alert behavior and the new timestamp/grouping/dismissal cases. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/repositories/processing-repository.ts packages/server/src/repositories/processing-repository.test.ts +git commit -m "feat: derive processing failure timestamps" +``` + +--- + +### Task 3: Expose and invalidate the dismissal tRPC mutation + +**Files:** +- Modify: `packages/server/src/routers/processing.ts` +- Modify: `packages/server/src/routers/processing.test.ts` + +**Interfaces:** +- `processing.status` output includes `datasets[].lastFailedAt`, `operations[].dismissed`, and `operations[].errorMessage`. +- `processing.alerts` output accepts one grouped alert with `datasetKeys`/`datasetLabels` and the operation ID as its stable `id`. +- New mutation: `processing.dismiss.input({ operationId: z.uuid() })` returns `{ dismissed: true }`. + +- [ ] **Step 1: Write failing router tests** + +Add tests that: + +- validate the new status/alert fields through the runtime output schema; +- call `processing.dismiss` with a valid UUID and assert the repository method receives it; +- assert the mutation invalidates `${userId}:processing.` after success; +- assert repository not-found errors reach the caller instead of being swallowed. + +Use the existing mocked `ProcessingRepository` class and cache mock pattern. The success assertion should be explicit: + +```ts +mockDismiss.mockResolvedValue({ dismissed: true }); +await expect(caller.dismiss({ operationId })).resolves.toEqual({ dismissed: true }); +expect(mockDismiss).toHaveBeenCalledWith(operationId); +expect(invalidateByPrefix).toHaveBeenCalledWith(`${userId}:processing.`); +``` + +- [ ] **Step 2: Run the router tests to verify they fail** + +Run: `pnpm test -- packages/server/src/routers/processing.test.ts` + +Expected: FAIL because the output schema and mutation do not exist. + +- [ ] **Step 3: Implement the router contract and mutation** + +Add the new nullable timestamp, boolean, and server-derived error fields to `statusOutputSchema`, update the grouped alert schema, import `queryCache`, and add: + +```ts +dismiss: protectedProcedure + .input(z.object({ operationId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + const result = await new ProcessingRepository(ctx.db, ctx.userId).dismiss(input.operationId); + await queryCache.invalidateByPrefix(`${ctx.userId}:processing.`); + return result; + }), +``` + +Do not add a broad cache invalidation or a client-only fallback. + +- [ ] **Step 4: Run the router tests to verify they pass** + +Run: `pnpm test -- packages/server/src/routers/processing.test.ts` + +Expected: PASS, including existing alerts, history, data-quality, and runtime-schema tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/routers/processing.ts packages/server/src/routers/processing.test.ts +git commit -m "feat: expose processing alert dismissal" +``` + +--- + +### Task 4: Add shared grouping and copy helpers + +**Files:** +- Modify: `packages/providers-meta/src/processing-status.ts` +- Modify: `packages/providers-meta/src/processing-status.test.ts` +- Modify: `packages/providers-meta/src/processing-alerts.ts` and its test if the alert type/helper changes require it + +**Interfaces:** +- Add `ProcessingFailureGroup` with `operationId`, `providerLabel`, `datasetLabels`, `status`, `failedAt`, `lastReadyAt`, `errorMessage`, and `dismissed`. +- Add `processingFailureGroups(input)` that consumes the structural status snapshot and returns only current, non-dismissed failed/blocked operation groups. +- Replace client use of `processingDatasetErrorMessage` with the operation's server-derived `errorMessage`; remove that helper and update its focused tests if it has no remaining production consumers. +- Change `ProcessingAlert` from one `datasetKey` to grouped `datasetKeys` and `datasetLabels`, with `id` equal to the operation ID. + +- [ ] **Step 1: Write failing shared-helper tests** + +Cover the exact user-visible rules: + +```ts +const groups = processingFailureGroups({ datasets: failedDatasets, operations }); +expect(groups).toEqual([ + expect.objectContaining({ + operationId: "operation-1", + datasetLabels: ["Activities", "Recovery", "Sleep"], + failedAt: "2026-07-22T16:00:00.000Z", + lastReadyAt: "2026-07-21T12:00:00.000Z", + }), +]); +``` + +Also assert that a dismissed operation yields no group, a later ready dataset yields no group, separate operation IDs remain separate, a missing `lastReadyAt` remains `null`, and the group uses `dataset.lastFailedAt`/`operation.errorMessage` without reading the timeline. + +- [ ] **Step 2: Run the shared tests to verify they fail** + +Run: `pnpm test -- packages/providers-meta/src/processing-status.test.ts packages/providers-meta/src/processing-alerts.test.ts` + +Expected: FAIL because the group type/function and grouped alert contract are not implemented. + +- [ ] **Step 3: Implement deterministic grouping and copy** + +Group by the operation ID that contains each current failed/blocked dataset, sort dataset labels in the server-provided dataset order, and derive the group's failure timestamp from the newest `dataset.lastFailedAt`. Use the operation's server-derived `errorMessage` and `dismissed` flag. Do not read or transform raw server event payloads in clients. Return one group per operation. + +Update the alert type and any helper that builds titles/messages so grouped provider sync alerts say what happened once and retain the existing action semantics. Keep strings concise and explicit about recovery: + +```ts +title: `${providerLabel} sync didn’t finish`; +message: `Dofek couldn’t get the latest data from ${providerLabel}. Reconnect ${providerLabel}, then start the sync again.`; +``` + +- [ ] **Step 4: Run the shared tests to verify they pass** + +Run: `pnpm test -- packages/providers-meta/src/processing-status.test.ts packages/providers-meta/src/processing-alerts.test.ts` + +Expected: PASS with the pre-existing status presentation tests unchanged except for intentional grouped-contract updates. + +- [ ] **Step 5: Commit** + +```bash +git add packages/providers-meta/src/processing-status.ts packages/providers-meta/src/processing-status.test.ts packages/providers-meta/src/processing-alerts.ts packages/providers-meta/src/processing-alerts.test.ts +git commit -m "feat: group processing failure presentation" +``` + +--- + +### Task 5: Update the web status widget and alerts page + +**Files:** +- Modify: `packages/web/src/components/ProcessingStatusWidget.tsx` +- Modify: `packages/web/src/components/ProcessingStatusWidget.test.tsx` +- Modify: `packages/web/src/components/ProcessingStatusWidget.stories.tsx` +- Modify: `packages/web/src/pages/AlertsPage.tsx` +- Modify: `packages/web/src/pages/AlertsPage.test.tsx` +- Modify: `packages/web/src/pages/AlertsPage.stories.tsx` + +**Interfaces:** +- Web components consume the updated tRPC response shape and call `trpc.processing.dismiss.useMutation()` with `operationId`. +- `ProcessingStatusWidget` renders no failed card when all current failure groups are dismissed, including when `alwaysVisible` is true. +- The web alerts page renders grouped labels/timestamps and offers both the existing recovery action and a `Dismiss` action. + +- [ ] **Step 1: Write failing web tests** + +Update fixtures with `lastFailedAt` and operation `dismissed`. Add tests that render six failed Wahoo datasets and assert: + +```tsx +expect(screen.getAllByText("Activities")).toHaveLength(1); +expect(screen.getByText("Failed: 16d ago")).toBeTruthy(); +expect(screen.getByText("Last successful update: 16d ago")).toBeTruthy(); +expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeTruthy(); +``` + +Clicking the button must call the mocked mutation with the operation ID and invalidate/refetch the status query. Add cases for a dismissed group being absent, a later ready status being absent, a single error message, and mutation errors remaining visible. Update AlertsPage tests to assert one grouped card, dismiss action invocation, and server error rendering. + +- [ ] **Step 2: Run the web tests to verify they fail** + +Run: `pnpm test -- packages/web/src/components/ProcessingStatusWidget.test.tsx packages/web/src/pages/AlertsPage.test.tsx` + +Expected: FAIL because the widgets still render dataset rows and do not expose dismiss mutations or grouped timestamps. + +- [ ] **Step 3: Implement the web presentation** + +Use `processingFailureGroups` for the widget. Render one grouped detail block per group with `formatRelativeTime(group.failedAt)` and the optional last-success line. Keep the current active/progress branch unchanged. Add a dismiss mutation with a pending-disabled button and an `onError` path that renders `error.message` in an alert region. Invalidate only the status query scope after success. + +In `AlertsPage`, use the grouped `ProcessingAlert` contract, render `datasetLabels` once, and add a separate dismiss button that calls `processing.dismiss` with `alert.id`; invalidate `processing.alerts` on success. Preserve the retry/reconnect actions and existing error boundary behavior. + +- [ ] **Step 4: Run the web tests to verify they pass** + +Run: `pnpm test -- packages/web/src/components/ProcessingStatusWidget.test.tsx packages/web/src/pages/AlertsPage.test.tsx` + +Expected: PASS, including existing loading/background-refetch/accessibility coverage. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/components/ProcessingStatusWidget.tsx packages/web/src/components/ProcessingStatusWidget.test.tsx packages/web/src/components/ProcessingStatusWidget.stories.tsx packages/web/src/pages/AlertsPage.tsx packages/web/src/pages/AlertsPage.test.tsx packages/web/src/pages/AlertsPage.stories.tsx +git commit -m "feat: clarify web processing failures" +``` + +--- + +### Task 6: Update the mobile status widget and alerts screen + +**Files:** +- Modify: `packages/mobile/components/ProcessingStatusWidget.tsx` +- Modify: `packages/mobile/components/ProcessingStatusWidget.test.tsx` +- Modify: `packages/mobile/components/ProcessingStatusWidget.stories.tsx` +- Modify: `packages/mobile/app/alerts.tsx` +- Modify: `packages/mobile/app-tests/alerts.test.tsx` +- Modify: mobile route/story fixtures that construct processing snapshots + +**Interfaces:** +- Mobile uses the same `processingFailureGroups` rules and `processing.dismiss` mutation input as web. +- Mobile route tests remain in `packages/mobile/app-tests/`; no helper/test/story files are added under `packages/mobile/app/`. +- Buttons expose accessible labels: `Dismiss Wahoo sync failure` for status groups and `Dismiss`/provider-specific labels for alert cards. + +- [ ] **Step 1: Write failing mobile tests** + +Mirror the web fixtures and assert one grouped Wahoo failure, failure/last-success timestamps, one error, dismissed-group absence, later-ready absence, and mutation-error visibility. In `app-tests/alerts.test.tsx`, assert that pressing dismiss calls the mutation with the alert operation ID and that the list invalidates after success. + +- [ ] **Step 2: Run the mobile tests to verify they fail** + +Run: `pnpm test -- packages/mobile/components/ProcessingStatusWidget.test.tsx packages/mobile/app-tests/alerts.test.tsx` + +Expected: FAIL because the mobile components still render one row per dataset and have no dismissal mutation. + +- [ ] **Step 3: Implement the mobile presentation** + +Apply the same shared grouping output as web, using `Text`, `View`, and `Pressable` styles already established by `SourceProcessingStatusCard`. Disable the dismiss control while pending, preserve native accessibility roles/live regions, and render server mutation errors with `accessibilityRole="alert"`. Update `alerts.tsx` to render grouped labels and a separate dismiss control without moving any route files. + +- [ ] **Step 4: Run the mobile tests to verify they pass** + +Run: `pnpm test -- packages/mobile/components/ProcessingStatusWidget.test.tsx packages/mobile/app-tests/alerts.test.tsx` + +Expected: PASS, including existing progress/accessibility and route-hygiene expectations. + +- [ ] **Step 5: Commit** + +```bash +git add packages/mobile/components/ProcessingStatusWidget.tsx packages/mobile/components/ProcessingStatusWidget.test.tsx packages/mobile/components/ProcessingStatusWidget.stories.tsx packages/mobile/app/alerts.tsx packages/mobile/app-tests/alerts.test.tsx packages/mobile/app-fixtures packages/mobile/app-stories +git commit -m "feat: clarify mobile processing failures" +``` + +--- + +### Task 7: Update fixtures, integration coverage, and validate the full change + +**Files:** +- Modify: all existing processing-status fixtures/stories/tests reported by TypeScript after the response contract changes, especially `packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts`, `packages/mobile/app-stories/(tabs)/processing-status-story-fixture.test.ts`, and provider route fixtures. +- Create: `packages/server/src/repositories/processing-repository.integration.test.ts` for real migration-backed status/alert coverage. +- Modify: `docs/production-incident-baseline.md` only if validation reveals a production incident or deploy/infrastructure issue; do not append an entry for ordinary local test failures. + +**Interfaces:** +- Every fixture compiles against the final status/alert contract. +- Real database coverage verifies the migrated dismissal relation through the repository, not only through mocked SQL. + +- [ ] **Step 1: Add/extend executable repository integration coverage** + +Seed one user, a failed provider operation containing multiple datasets, a later successful operation, and a dismissal row. Assert: + +```ts +expect((await repository.status({ providerId: "wahoo" })).datasets).toEqual( + expect.arrayContaining([expect.objectContaining({ lastFailedAt: expect.any(String) })]), +); +await repository.dismiss(failedOperationId); +await expect(repository.alerts()).resolves.toEqual(expect.objectContaining({ alerts: [] })); +``` + +Then seed a later ready operation and assert the old failure is not current even if its history remains present. + +- [ ] **Step 2: Run the integration suite to verify the new coverage passes** + +Run: `pnpm test:integration -- packages/server/src/repositories/processing-repository.integration.test.ts` + +Expected: PASS against the current Compose Postgres dependency, with no historical backfill replay. + +- [ ] **Step 3: Run the focused unit suites together** + +Run: `pnpm test -- packages/providers-meta/src/processing-status.test.ts packages/providers-meta/src/processing-alerts.test.ts packages/server/src/repositories/processing-repository.test.ts packages/server/src/routers/processing.test.ts packages/web/src/components/ProcessingStatusWidget.test.tsx packages/web/src/pages/AlertsPage.test.tsx packages/mobile/components/ProcessingStatusWidget.test.tsx packages/mobile/app-tests/alerts.test.tsx` + +Expected: PASS. + +- [ ] **Step 4: Run typecheck and lint** + +Run: `pnpm typecheck` + +Expected: PASS with no response-shape errors in web, mobile, server, stories, or fixtures. + +Run: `pnpm lint` + +Expected: PASS with no migration-policy, route-hygiene, or accessibility violations. + +- [ ] **Step 5: Review the final diff and test for scope** + +Run: + +```bash +git diff --check +git status --short +git diff --stat HEAD~7..HEAD +``` + +Confirm that `.nx/plans/` remains untracked and untouched, no provider/sync worker files changed, no duplicate failure rows remain in either platform, and all new server errors surface through existing error/telemetry paths. + +## Execution handoff + +After this plan is approved, execute it with `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Each task ends with its own focused test run and commit; stop at the review checkpoints if a test reveals a root-cause issue outside the approved design. From 7f0c22e92472bbb8a5f496c39195b9001183c8e7 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 14:59:36 -0700 Subject: [PATCH 10/46] docs: clarify dismissal ownership tests --- .../plans/2026-08-08-processing-status-alerts.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-08-processing-status-alerts.md b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md index 24c7e9da77..fd38d00d68 100644 --- a/docs/superpowers/plans/2026-08-08-processing-status-alerts.md +++ b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md @@ -23,7 +23,7 @@ - `src/db/schema/processing.ts`: Drizzle declaration for the dismissal relation. - `drizzle/0071_processing_alert_dismissal.sql` and `drizzle/meta/*`: forward migration and generated schema metadata. -- `src/db/processing-alert-dismissals.integration.test.ts`: executable PostgreSQL coverage for the new relation's ownership and idempotency behavior. +- `src/db/processing-alert-dismissals.integration.test.ts`: executable PostgreSQL coverage for the new relation's foreign keys and uniqueness behavior. - `packages/server/src/repositories/processing-repository.ts`: derive `lastFailedAt`/`errorMessage`, read dismissals, group current failures for alerts, and persist scoped dismissals. - `packages/server/src/repositories/processing-repository.test.ts`: repository unit coverage for timestamps, grouping, dismissal state, ownership, and idempotency. - `packages/server/src/routers/processing.ts`: validate the new response fields and expose `processing.dismiss`. @@ -56,7 +56,7 @@ - [ ] **Step 1: Write the failing integration test** -Add a Docker-backed test that creates two fixture users and one processing operation for the first user, inserts a dismissal for that operation with a direct SQL helper, verifies the row can be read, verifies the table's duplicate primary key is enforced, and verifies a dismissal for the second user cannot reference the first user's operation through the foreign key relationship. +Add a Docker-backed test that creates one fixture user and one processing operation, inserts a dismissal for that operation with a direct SQL helper, verifies the row can be read, verifies the table's duplicate primary key is enforced, and verifies nonexistent user and operation references fail through the actual foreign keys. Cross-user ownership belongs to the repository mutation test in Task 2 because independent `user_id` and `operation_id` foreign keys do not enforce a composite user-operation relationship. The database assertion must exercise the actual foreign keys rather than checking SQL text: @@ -69,7 +69,10 @@ const insertDismissal = (userId: string, operationId: string) => database.execut await expect(insertDismissal(firstUserId, operationId)).resolves.toHaveLength(1); await expect(insertDismissal(firstUserId, operationId)).rejects.toThrow(); -await expect(insertDismissal(secondUserId, operationId)).rejects.toThrow(); +await expect(insertDismissal("10000000-0000-4000-8000-000000000099", operationId)).rejects.toThrow(); +await expect( + insertDismissal(firstUserId, "10000000-0000-4000-8000-000000000099"), +).rejects.toThrow(); ``` - [ ] **Step 2: Run the test to verify it fails** From ee746b41113e1146a1dae9db02fc2de73e37d238 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 15:06:58 -0700 Subject: [PATCH 11/46] feat: add processing alert dismissals --- drizzle/0071_processing_alert_dismissal.sql | 6 ++ drizzle/meta/_journal.json | 7 ++ ...ssing-alert-dismissals.integration.test.ts | 77 +++++++++++++++++++ src/db/schema/processing.ts | 26 +++++++ 4 files changed, 116 insertions(+) create mode 100644 drizzle/0071_processing_alert_dismissal.sql create mode 100644 src/db/processing-alert-dismissals.integration.test.ts diff --git a/drizzle/0071_processing_alert_dismissal.sql b/drizzle/0071_processing_alert_dismissal.sql new file mode 100644 index 0000000000..3bfb0240de --- /dev/null +++ b/drizzle/0071_processing_alert_dismissal.sql @@ -0,0 +1,6 @@ +CREATE TABLE fitness.processing_alert_dismissal ( + user_id uuid NOT NULL REFERENCES fitness.user_profile (id) ON DELETE CASCADE, + operation_id uuid NOT NULL REFERENCES fitness.processing_operation (id) ON DELETE CASCADE, + dismissed_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT processing_alert_dismissal_pkey PRIMARY KEY (user_id, operation_id) +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8f0e16c537..b0e4d2f3b1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -582,6 +582,13 @@ "when": 1785770820000, "tag": "0070_repair_subjective_inputs", "breakpoints": true + }, + { + "idx": 83, + "version": "7", + "when": 1786230300000, + "tag": "0071_processing_alert_dismissal", + "breakpoints": true } ] } diff --git a/src/db/processing-alert-dismissals.integration.test.ts b/src/db/processing-alert-dismissals.integration.test.ts new file mode 100644 index 0000000000..b66cdc31ce --- /dev/null +++ b/src/db/processing-alert-dismissals.integration.test.ts @@ -0,0 +1,77 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { setupTestDatabase, type TestContext } from "./test-helpers.ts"; + +describe("processing alert dismissals (integration)", () => { + const userId = "10000000-0000-4000-8000-000000000001"; + const operationId = "10000000-0000-4000-8000-000000000010"; + const missingUserId = "10000000-0000-4000-8000-000000000099"; + const missingOperationId = "10000000-0000-4000-8000-000000000199"; + let testContext: TestContext; + + beforeAll(async () => { + testContext = await setupTestDatabase(); + await testContext.db.execute(sql` + INSERT INTO fitness.user_profile (id, name) + VALUES (${userId}::uuid, 'Processing Alert User') + `); + await testContext.db.execute(sql` + INSERT INTO fitness.processing_operation ( + id, + user_id, + provider_id, + kind, + external_correlation_key, + dataset_keys + ) + VALUES ( + ${operationId}::uuid, + ${userId}::uuid, + 'apple_health', + 'provider_sync', + 'processing-alert-dismissal-test', + ARRAY['recovery']::text[] + ) + `); + }, 120_000); + + afterAll(async () => { + await testContext?.cleanup(); + }); + + const insertDismissal = (dismissalUserId: string, dismissalOperationId: string) => + testContext.db.execute<{ + user_id: string; + operation_id: string; + dismissed_at: string; + }>(sql` + INSERT INTO fitness.processing_alert_dismissal (user_id, operation_id) + VALUES (${dismissalUserId}::uuid, ${dismissalOperationId}::uuid) + RETURNING user_id, operation_id, dismissed_at + `); + + it("enforces duplicate and independent foreign-key constraints", async () => { + await expect(insertDismissal(userId, operationId)).resolves.toHaveLength(1); + await expect(insertDismissal(userId, operationId)).rejects.toThrow(); + await expect(insertDismissal(missingUserId, operationId)).rejects.toThrow(); + await expect(insertDismissal(userId, missingOperationId)).rejects.toThrow(); + + const rows = await testContext.db.execute<{ + user_id: string; + operation_id: string; + dismissed_at: string; + }>(sql` + SELECT user_id, operation_id, dismissed_at + FROM fitness.processing_alert_dismissal + WHERE user_id = ${userId}::uuid + AND operation_id = ${operationId}::uuid + `); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + user_id: userId, + operation_id: operationId, + dismissed_at: expect.any(String), + }); + }); +}); diff --git a/src/db/schema/processing.ts b/src/db/schema/processing.ts index 6ae61b625f..456c2d813c 100644 --- a/src/db/schema/processing.ts +++ b/src/db/schema/processing.ts @@ -6,6 +6,7 @@ import { index, integer, jsonb, + primaryKey, text, timestamp, unique, @@ -52,6 +53,31 @@ export const processingOperation = fitness.table( ], ); +export const processingAlertDismissal = fitness.table( + "processing_alert_dismissal", + { + userId: uuid("user_id").notNull(), + operationId: uuid("operation_id").notNull(), + dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ + name: "processing_alert_dismissal_pkey", + columns: [table.userId, table.operationId], + }), + foreignKey({ + name: "processing_alert_dismissal_user_fk", + columns: [table.userId], + foreignColumns: [userProfile.id], + }).onDelete("cascade"), + foreignKey({ + name: "processing_alert_dismissal_operation_fk", + columns: [table.operationId], + foreignColumns: [processingOperation.id], + }).onDelete("cascade"), + ], +); + export const processingStageEvent = fitness.table( "processing_stage_event", { From c9c33d06e33d892d09b5ac02e470dab8239d02c0 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 15:14:54 -0700 Subject: [PATCH 12/46] fix: name processing alert dismissal fks --- drizzle/0071_processing_alert_dismissal.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drizzle/0071_processing_alert_dismissal.sql b/drizzle/0071_processing_alert_dismissal.sql index 3bfb0240de..d104288c49 100644 --- a/drizzle/0071_processing_alert_dismissal.sql +++ b/drizzle/0071_processing_alert_dismissal.sql @@ -1,6 +1,6 @@ CREATE TABLE fitness.processing_alert_dismissal ( - user_id uuid NOT NULL REFERENCES fitness.user_profile (id) ON DELETE CASCADE, - operation_id uuid NOT NULL REFERENCES fitness.processing_operation (id) ON DELETE CASCADE, + user_id uuid NOT NULL CONSTRAINT processing_alert_dismissal_user_fk REFERENCES fitness.user_profile (id) ON DELETE CASCADE, + operation_id uuid NOT NULL CONSTRAINT processing_alert_dismissal_operation_fk REFERENCES fitness.processing_operation (id) ON DELETE CASCADE, dismissed_at timestamp with time zone NOT NULL DEFAULT now(), CONSTRAINT processing_alert_dismissal_pkey PRIMARY KEY (user_id, operation_id) ); From 73395bf04854ff407319958c6ff93fe3e787aded Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 15:28:20 -0700 Subject: [PATCH 13/46] feat: derive processing failure timestamps --- .../processing-repository.test.ts | 290 +++++++++++++++++- .../src/repositories/processing-repository.ts | 262 ++++++++++++---- 2 files changed, 488 insertions(+), 64 deletions(-) diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index 87b6cb8c77..5017b3ac5d 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -1,19 +1,33 @@ +import { TRPCError } from "@trpc/server"; import type { Database } from "dofek/db"; import type { ProcessingOperationWithEvents } from "dofek/processing/processing-event-store"; import type { DerivedProcessingStatus } from "dofek/processing/processing-state"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockDeriveProcessingState, mockListProcessingHistory, mockListScopedProcessingOperations } = - vi.hoisted(() => ({ - mockDeriveProcessingState: vi.fn(), - mockListProcessingHistory: vi.fn(), - mockListScopedProcessingOperations: vi.fn(), - })); +const { + mockDeriveProcessingState, + mockExecuteWithSchema, + mockListProcessingHistory, + mockListScopedProcessingOperations, +} = vi.hoisted(() => ({ + mockDeriveProcessingState: vi.fn(), + mockExecuteWithSchema: vi.fn(), + mockListProcessingHistory: vi.fn(), + mockListScopedProcessingOperations: vi.fn(), +})); vi.mock("dofek/processing/processing-state", () => ({ deriveProcessingState: mockDeriveProcessingState, })); +vi.mock("../lib/typed-sql.ts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + executeWithSchema: (...args: unknown[]) => mockExecuteWithSchema(...args), + }; +}); + vi.mock("dofek/processing/processing-event-store", () => ({ listProcessingHistory: mockListProcessingHistory, listScopedProcessingOperations: mockListScopedProcessingOperations, @@ -75,6 +89,7 @@ describe("ProcessingRepository", () => { vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(now); + mockExecuteWithSchema.mockResolvedValue([]); mockDeriveProcessingState.mockImplementation((input: { datasetKeys: readonly string[] }) => ({ overallStatus: "ready", datasets: input.datasetKeys.map((datasetKey) => ({ @@ -120,6 +135,7 @@ describe("ProcessingRepository", () => { currentStage: null, progressPercentage: null, lastAdvancedAt: null, + lastFailedAt: null, lastReadyAt: null, }, ]); @@ -282,6 +298,7 @@ describe("ProcessingRepository", () => { currentStage: "cdc", progressPercentage: 40, lastAdvancedAt: "2026-07-22T18:00:00.000Z", + lastFailedAt: null, lastReadyAt: "2026-07-22T17:00:00.000Z", }, ]); @@ -334,6 +351,7 @@ describe("ProcessingRepository", () => { currentStage: "analytics", progressPercentage: 75, lastAdvancedAt: null, + lastFailedAt: null, lastReadyAt: null, }); }); @@ -357,9 +375,135 @@ describe("ProcessingRepository", () => { const result = await repository.status({ datasets: ["activity"] }); expect(result.datasets[0]?.lastAdvancedAt).toBeNull(); + expect(result.datasets[0]?.lastFailedAt).toBeNull(); expect(result.datasets[0]?.lastReadyAt).toBeNull(); }); + it("derives dataset failure timestamps from the failed event occurrence time", async () => { + const failedAt = new Date("2026-07-22T16:00:00.000Z"); + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + createdAt: new Date("2026-07-22T15:00:00.000Z"), + events: [ + event(1, { + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: failedAt, + errorMessage: "Activity analytics failed.", + }), + ], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const result = await repository.status({ datasets: ["activity"] }); + + expect(result.datasets[0]?.lastAdvancedAt).toBe("2026-07-22T18:00:00.000Z"); + expect(result.datasets[0]?.lastFailedAt).toBe("2026-07-22T16:00:00.000Z"); + expect(result.operations[0]?.dismissed).toBe(false); + expect(result.operations[0]?.errorMessage).toBe("Activity analytics failed."); + }); + + it("keeps a dataset ready when a later operation succeeds and suppresses the old alert", async () => { + const olderFailure = operation({ + id: "10000000-0000-4000-8000-000000000041", + createdAt: new Date("2026-07-22T16:00:00.000Z"), + events: [ + event(1, { + operationId: "10000000-0000-4000-8000-000000000041", + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T16:30:00.000Z"), + }), + ], + }); + const laterReady = operation({ + id: "10000000-0000-4000-8000-000000000042", + createdAt: new Date("2026-07-22T17:30:00.000Z"), + events: [ + event(1, { + operationId: "10000000-0000-4000-8000-000000000042", + stage: "cache_refresh", + status: "succeeded", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T17:45:00.000Z"), + }), + ], + }); + mockListScopedProcessingOperations.mockResolvedValue([laterReady, olderFailure]); + mockDeriveProcessingState + .mockReturnValueOnce({ + overallStatus: "ready", + datasets: [ + { + datasetKey: "activity", + currentStage: "cache_refresh", + status: "ready", + progressPercentage: 100, + lastAdvancedAt: new Date("2026-07-22T17:45:00.000Z"), + }, + ], + }) + .mockReturnValueOnce({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: new Date("2026-07-22T16:30:00.000Z"), + }, + ], + }) + .mockReturnValueOnce({ + overallStatus: "ready", + datasets: [ + { + datasetKey: "activity", + currentStage: "cache_refresh", + status: "ready", + progressPercentage: 100, + lastAdvancedAt: new Date("2026-07-22T17:45:00.000Z"), + }, + ], + }) + .mockReturnValueOnce({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: new Date("2026-07-22T16:30:00.000Z"), + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const status = await repository.status({ datasets: ["activity"] }); + const alerts = await repository.alerts(); + + expect(status.datasets[0]?.status).toBe("ready"); + expect(status.datasets[0]?.lastFailedAt).toBe("2026-07-22T16:30:00.000Z"); + expect(alerts.alerts).toEqual([]); + }); + it.each([ "failed", "blocked", @@ -465,6 +609,7 @@ describe("ProcessingRepository", () => { providerId: "garmin", providerLabel: "Garmin", datasetKey: "providers", + datasetKeys: ["providers"], occurredAt: "2026-07-22T18:00:00.000Z", title: "Garmin summary wasn’t updated", message: @@ -734,6 +879,7 @@ describe("ProcessingRepository", () => { providerId: null, providerLabel: null, datasetKey: "activity", + datasetKeys: ["activity"], occurredAt: latestFailureAt.toISOString(), title: "Activities wasn’t updated", message: @@ -771,6 +917,7 @@ describe("ProcessingRepository", () => { alerts: [ expect.objectContaining({ occurredAt: "2026-07-22T18:00:00.000Z", + datasetKeys: ["activity"], title: "Activities wasn’t updated", message: "Dofek imported your file, but couldn’t update activities. Your previously imported data is still available.", @@ -816,6 +963,7 @@ describe("ProcessingRepository", () => { providerId: "garmin", providerLabel: "Garmin", datasetKey: "activity", + datasetKeys: ["activity"], occurredAt: "2026-07-22T18:00:00.000Z", title: "Activities wasn’t updated", message: @@ -841,6 +989,7 @@ describe("ProcessingRepository", () => { currentStage: "analytics", progressPercentage: null, lastAdvancedAt: "2026-07-22T18:00:00.000Z", + lastFailedAt: null, lastReadyAt: null, }, ], @@ -875,6 +1024,110 @@ describe("ProcessingRepository", () => { }); }); + it("groups several failed datasets in one provider operation into one alert", async () => { + const latestFailureAt = new Date("2026-07-22T17:50:00.000Z"); + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + providerId: "garmin", + kind: "provider_sync", + datasetKeys: ["activity", "recovery", "sleep"], + outputManifest: { + activity: ["relational"], + recovery: ["relational"], + sleep: ["relational"], + }, + events: [ + event(1, { + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T17:10:00.000Z"), + }), + event(2, { + stage: "analytics", + status: "failed", + datasetKey: "recovery", + occurredAt: new Date("2026-07-22T17:30:00.000Z"), + }), + event(3, { + stage: "analytics", + status: "failed", + datasetKey: "sleep", + occurredAt: latestFailureAt, + }), + ], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + { + datasetKey: "recovery", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + { + datasetKey: "sleep", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const alerts = await repository.alerts(); + + expect(alerts.alerts).toHaveLength(1); + expect(alerts.alerts[0]?.datasetKeys).toEqual(["activity", "recovery", "sleep"]); + expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:50:00.000Z"); + }); + + it("marks dismissed operations in status and omits them from alerts", async () => { + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + events: [ + event(1, { + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T17:20:00.000Z"), + }), + ], + }), + ]); + mockExecuteWithSchema.mockResolvedValue([{ operation_id: operationId }]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const status = await repository.status({ datasets: ["activity"] }); + const alerts = await repository.alerts(); + + expect(status.operations[0]?.dismissed).toBe(true); + expect(alerts.alerts).toEqual([]); + }); + it("does not alert for resolved or in-progress datasets", async () => { mockListScopedProcessingOperations.mockResolvedValue([operation()]); const repository = new ProcessingRepository(database, userId); @@ -1044,4 +1297,29 @@ describe("ProcessingRepository", () => { limit: 25, }); }); + + it("inserts a dismissal once and returns dismissed on repeated requests", async () => { + mockExecuteWithSchema + .mockResolvedValueOnce([{ operation_id: operationId }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ operation_id: operationId }]); + const repository = new ProcessingRepository(database, userId); + + await expect(repository.dismiss(operationId)).resolves.toEqual({ dismissed: true }); + await expect(repository.dismiss(operationId)).resolves.toEqual({ dismissed: true }); + expect(mockExecuteWithSchema).toHaveBeenCalledTimes(3); + }); + + it.each([ + "10000000-0000-4000-8000-000000000091", + "10000000-0000-4000-8000-000000000092", + ])("rejects dismissing an unknown or foreign operation (%s)", async (targetOperationId) => { + mockExecuteWithSchema.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const repository = new ProcessingRepository(database, userId); + + await expect(repository.dismiss(targetOperationId)).rejects.toMatchObject>({ + code: "NOT_FOUND", + message: "Processing operation not found", + }); + }); }); diff --git a/packages/server/src/repositories/processing-repository.ts b/packages/server/src/repositories/processing-repository.ts index b52c76587c..4ccc2c7e34 100644 --- a/packages/server/src/repositories/processing-repository.ts +++ b/packages/server/src/repositories/processing-repository.ts @@ -1,6 +1,8 @@ +import { TRPCError } from "@trpc/server"; import type { ProcessingAlert } from "@dofek/providers/processing-alerts"; import { providerLabel } from "@dofek/providers/providers"; import type { Database } from "dofek/db"; +import { sql } from "drizzle-orm"; import { DATASET_CONTRACTS, PROCESSING_DATASET_KEYS, @@ -18,8 +20,11 @@ import { type ProcessingEventStatus, type ProcessingStage, } from "dofek/processing/processing-state"; +import { z } from "zod"; +import { executeWithSchema } from "../lib/typed-sql.ts"; const DEFAULT_DELAY_MS = 15 * 60 * 1_000; +const dismissalRowSchema = z.object({ operation_id: z.uuid() }); export interface ProcessingStatusScope { providerId: string | null; @@ -33,6 +38,7 @@ export interface ProcessingStatusDataset { currentStage: ProcessingStage | null; progressPercentage: number | null; lastAdvancedAt: string | null; + lastFailedAt: string | null; lastReadyAt: string | null; } @@ -43,6 +49,8 @@ export interface ProcessingStatusOperation { createdAt: string; status: DerivedProcessingStatus; datasets: ProcessingDatasetKey[]; + dismissed: boolean; + errorMessage: string | null; timeline: Array<{ sequence: number; stage: ProcessingStage; @@ -67,6 +75,7 @@ export interface ProcessingStatusSnapshot { interface ServerProcessingAlert extends Omit { datasetKey: ProcessingDatasetKey; + datasetKeys: ProcessingDatasetKey[]; } export interface ProcessingAlertsSnapshot { @@ -79,28 +88,97 @@ function datasetSubject(datasetKey: ProcessingDatasetKey, label: string): string return label.toLowerCase(); } -function buildProcessingAlert( - dataset: ProcessingStatusDataset, - operation: ProcessingStatusOperation, -): ServerProcessingAlert { - const failedEvent = [...operation.timeline] +function processingOperationNotFoundError(): TRPCError { + return new TRPCError({ code: "NOT_FOUND", message: "Processing operation not found" }); +} + +function compareByOccurredAtDescending( + left: T, + right: T, +) { + const timeDifference = + new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(); + return timeDifference === 0 ? right.sequence - left.sequence : timeDifference; +} + +function latestFailedEventForDatasets< + T extends { + sequence: number; + stage: ProcessingStage; + status: ProcessingEventStatus; + datasetKey: ProcessingDatasetKey | null; + occurredAt: Date | string; + errorCode: string | null; + errorMessage: string | null; + }, +>(events: readonly T[], datasetKeys: readonly ProcessingDatasetKey[]) { + const datasetKeySet = new Set(datasetKeys); + return [...events] .filter( (event) => event.status === "failed" && - (event.datasetKey === null || event.datasetKey === dataset.key), + (event.datasetKey === null || datasetKeySet.has(event.datasetKey)), ) - .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0]; + .sort(compareByOccurredAtDescending)[0]; +} + +function formatList(values: readonly string[]): string { + if (values.length === 0) return ""; + if (values.length === 1) return values[0] ?? ""; + if (values.length === 2) return `${values[0]} and ${values[1]}`; + return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}`; +} + +function groupedDatasetSubject(datasets: readonly ProcessingStatusDataset[]): string { + return formatList(datasets.map((dataset) => datasetSubject(dataset.key, dataset.label))); +} + +function groupedDatasetTitle(datasets: readonly ProcessingStatusDataset[]): string { + if (datasets.length === 1) return datasets[0]?.label ?? ""; + return formatList( + datasets.map((dataset) => + dataset.key === "providers" ? "summary" : dataset.label.toLowerCase(), + ), + ); +} + +function buildProcessingAlert( + datasets: readonly ProcessingStatusDataset[], + operation: ProcessingStatusOperation, +): ServerProcessingAlert { + const primaryDataset = datasets[0]; + if (!primaryDataset) { + throw new Error("Processing alerts require at least one dataset"); + } + const failedEvent = latestFailedEventForDatasets( + operation.timeline, + datasets.map((dataset) => dataset.key), + ); const sourceLabel = operation.providerId ? providerLabel(operation.providerId) : null; - const subject = datasetSubject(dataset.key, dataset.label); - const occurredAt = failedEvent?.occurredAt ?? dataset.lastAdvancedAt ?? operation.createdAt; + const subject = + datasets.length === 1 + ? datasetSubject(primaryDataset.key, primaryDataset.label) + : groupedDatasetSubject(datasets); + const titleLabel = + datasets.length === 1 ? primaryDataset.label : groupedDatasetTitle(datasets); + const titleSuffix = datasets.length === 1 ? "wasn’t updated" : "weren’t updated"; + const occurredAt = + failedEvent?.occurredAt ?? + datasets + .map((dataset) => dataset.lastFailedAt ?? dataset.lastAdvancedAt) + .find((value): value is string => value !== null) ?? + operation.createdAt; + const datasetKeys = datasets.map((dataset) => dataset.key); + const alertId = `${operation.id}:${datasetKeys.join(",")}`; if (operation.kind === "file_import") { const importedFile = sourceLabel ? `the ${sourceLabel} file` : "your file"; return { - id: `${operation.id}:${dataset.key}`, + id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: dataset.key, + datasetKey: primaryDataset.key, + datasetKeys, occurredAt, title: failedEvent?.stage === "ingest" @@ -108,8 +186,8 @@ function buildProcessingAlert( ? `${sourceLabel} file wasn’t imported` : "Your file wasn’t imported" : sourceLabel - ? `${sourceLabel} ${subject} wasn’t updated` - : `${dataset.label} wasn’t updated`, + ? `${sourceLabel} ${subject} ${titleSuffix}` + : `${titleLabel} ${titleSuffix}`, message: failedEvent?.stage === "ingest" ? `Dofek couldn’t finish importing ${importedFile}. Check that you selected the correct file, then import it again.` @@ -122,10 +200,11 @@ function buildProcessingAlert( if (operation.kind === "provider_sync" && sourceLabel) { if (failedEvent?.stage === "ingest" || failedEvent?.errorCode === "provider_sync_failed") { return { - id: `${operation.id}:${dataset.key}`, + id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: dataset.key, + datasetKey: primaryDataset.key, + datasetKeys, occurredAt, title: `${sourceLabel} couldn’t sync`, message: `Dofek couldn’t get the latest data from ${sourceLabel}. Reconnect ${sourceLabel}, then start the sync again.`, @@ -135,14 +214,15 @@ function buildProcessingAlert( } return { - id: `${operation.id}:${dataset.key}`, + id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: dataset.key, + datasetKey: primaryDataset.key, + datasetKeys, occurredAt, - title: `${sourceLabel} ${subject} wasn’t updated`, + title: `${sourceLabel} ${subject} ${titleSuffix}`, message: - dataset.key === "providers" + datasets.length === 1 && primaryDataset.key === "providers" ? `Your ${sourceLabel} data synced, but its totals and latest-sync information couldn’t be refreshed. Your previously synced data is still available.` : `Your ${sourceLabel} data synced, but Dofek couldn’t update ${subject}. Your previously synced data is still available.`, action: "retry_sync", @@ -151,12 +231,13 @@ function buildProcessingAlert( } return { - id: `${operation.id}:${dataset.key}`, + id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: dataset.key, + datasetKey: primaryDataset.key, + datasetKeys, occurredAt, - title: `${dataset.label} wasn’t updated`, + title: `${titleLabel} ${titleSuffix}`, message: `Dofek couldn’t update ${subject}. Your existing data is still available. Contact support for help.`, action: "contact_support", actionLabel: "Contact support", @@ -215,6 +296,26 @@ export class ProcessingRepository { this.#userId = userId; } + async #loadDismissedOperationIds(operationIds: readonly string[]): Promise> { + if (operationIds.length === 0) return new Set(); + const rows = await executeWithSchema( + this.#database, + dismissalRowSchema, + sql` + SELECT operation_id + FROM fitness.processing_alert_dismissal + WHERE user_id = ${this.#userId}::uuid + AND operation_id = ANY( + ARRAY[${sql.join( + operationIds.map((operationId) => sql`${operationId}::uuid`), + sql`, `, + )}]::uuid[] + ) + `, + ); + return new Set(rows.map((row) => row.operation_id)); + } + async status(input: { providerId?: string; datasets?: readonly ProcessingDatasetKey[]; @@ -227,6 +328,9 @@ export class ProcessingRepository { providerId: input.providerId, datasetKeys: requestedDatasets, }); + const dismissedOperationIds = await this.#loadDismissedOperationIds( + operations.map((operation) => operation.id), + ); const operationsWithState = operations.map((operation) => ({ operation, state: operationState(operation, now), @@ -241,6 +345,14 @@ export class ProcessingRepository { const lastReadyDataset = relevant .flatMap(({ state }) => state.datasets) .find((dataset) => dataset.datasetKey === datasetKey && dataset.status === "ready"); + const lastFailedEvent = relevant + .flatMap(({ operation }) => operation.events) + .filter( + (event) => + event.status === "failed" && + (event.datasetKey === null || event.datasetKey === datasetKey), + ) + .sort(compareByOccurredAtDescending)[0]; const contract = DATASET_CONTRACTS.find((candidate) => candidate.key === datasetKey); if (!contract) throw new Error(`Missing processing dataset contract for ${datasetKey}`); return { @@ -250,6 +362,7 @@ export class ProcessingRepository { currentStage: latest?.currentStage ?? null, progressPercentage: latest?.progressPercentage ?? null, lastAdvancedAt: latest?.lastAdvancedAt?.toISOString() ?? null, + lastFailedAt: lastFailedEvent?.occurredAt.toISOString() ?? null, lastReadyAt: lastReadyDataset?.lastAdvancedAt?.toISOString() ?? null, }; }); @@ -258,51 +371,84 @@ export class ProcessingRepository { scope: { providerId: input.providerId ?? null, datasets: requestedDatasets }, overallStatus: aggregateStatus(datasets.map((dataset) => dataset.status)), datasets, - operations: operationsWithState.map(({ operation, state }) => ({ - id: operation.id, - providerId: operation.providerId, - kind: operation.kind, - createdAt: operation.createdAt.toISOString(), - status: aggregateStatus( - state.datasets - .filter((dataset) => requestedDatasetSet.has(dataset.datasetKey)) - .map((dataset) => dataset.status), - ), - datasets: operation.datasetKeys.filter((datasetKey) => requestedDatasetSet.has(datasetKey)), - timeline: operation.events - .filter( - (event) => - event.modelName === null && - (event.datasetKey === null || requestedDatasetSet.has(event.datasetKey)), - ) - .map((event) => ({ - sequence: event.sequence, - stage: event.stage, - status: event.status, - datasetKey: event.datasetKey, - outputPath: event.outputPath, - occurredAt: event.occurredAt.toISOString(), - progressPercentage: event.progressPercentage, - message: event.message, - errorCode: event.errorCode, - errorMessage: event.errorMessage, - })), - })), + operations: operationsWithState.map(({ operation, state }) => { + const operationDatasetKeys = operation.datasetKeys.filter((datasetKey) => + requestedDatasetSet.has(datasetKey), + ); + const operationError = latestFailedEventForDatasets(operation.events, operationDatasetKeys); + return { + id: operation.id, + providerId: operation.providerId, + kind: operation.kind, + createdAt: operation.createdAt.toISOString(), + status: aggregateStatus( + state.datasets + .filter((dataset) => requestedDatasetSet.has(dataset.datasetKey)) + .map((dataset) => dataset.status), + ), + datasets: operationDatasetKeys, + dismissed: dismissedOperationIds.has(operation.id), + errorMessage: operationError?.errorMessage ?? null, + timeline: operation.events + .filter( + (event) => + event.modelName === null && + (event.datasetKey === null || requestedDatasetSet.has(event.datasetKey)), + ) + .map((event) => ({ + sequence: event.sequence, + stage: event.stage, + status: event.status, + datasetKey: event.datasetKey, + outputPath: event.outputPath, + occurredAt: event.occurredAt.toISOString(), + progressPercentage: event.progressPercentage, + message: event.message, + errorCode: event.errorCode, + errorMessage: event.errorMessage, + })), + }; + }), }; } async alerts(): Promise { const snapshot = await this.status({}); - const alerts = snapshot.datasets.flatMap((dataset) => { - if (dataset.status !== "failed" && dataset.status !== "blocked") return []; - const currentOperation = snapshot.operations.find((operation) => - operation.datasets.includes(dataset.key), - ); - return currentOperation ? [buildProcessingAlert(dataset, currentOperation)] : []; + const alertableDatasets = new Map( + snapshot.datasets + .filter((dataset) => dataset.status === "failed" || dataset.status === "blocked") + .map((dataset) => [dataset.key, dataset] as const), + ); + const alerts = snapshot.operations.flatMap((operation) => { + if (operation.dismissed) return []; + const groupedDatasets = operation.datasets + .map((datasetKey) => alertableDatasets.get(datasetKey)) + .filter((dataset): dataset is ProcessingStatusDataset => dataset !== undefined); + return groupedDatasets.length === 0 ? [] : [buildProcessingAlert(groupedDatasets, operation)]; }); return { generatedAt: snapshot.generatedAt, alerts }; } + async dismiss(operationId: string): Promise<{ dismissed: true }> { + const insertedRows = await executeWithSchema( + this.#database, + dismissalRowSchema, + sql` + INSERT INTO fitness.processing_alert_dismissal (user_id, operation_id) + SELECT ${this.#userId}::uuid, operation.id + FROM fitness.processing_operation operation + WHERE operation.id = ${operationId}::uuid + AND operation.user_id = ${this.#userId}::uuid + ON CONFLICT (user_id, operation_id) DO NOTHING + RETURNING operation_id + `, + ); + if (insertedRows.length > 0) return { dismissed: true }; + const dismissedOperationIds = await this.#loadDismissedOperationIds([operationId]); + if (dismissedOperationIds.has(operationId)) return { dismissed: true }; + throw processingOperationNotFoundError(); + } + async history(input: { cursor?: string | null; limit: number }) { return listProcessingHistory(this.#database, { userId: this.#userId, From 34be29b75566fda165476ee17f220c3868013246 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 15:34:17 -0700 Subject: [PATCH 14/46] fix: scope processing alerts to current ops --- .../processing-repository.test.ts | 80 +++++++++++++++++++ .../src/repositories/processing-repository.ts | 9 +++ 2 files changed, 89 insertions(+) diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index 5017b3ac5d..fdd3c49a0d 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -1,5 +1,6 @@ import { TRPCError } from "@trpc/server"; import type { Database } from "dofek/db"; +import { PgDialect } from "drizzle-orm/pg-core"; import type { ProcessingOperationWithEvents } from "dofek/processing/processing-event-store"; import type { DerivedProcessingStatus } from "dofek/processing/processing-state"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -39,6 +40,7 @@ const operationId = "10000000-0000-4000-8000-000000000001"; const userId = "10000000-0000-4000-8000-000000000002"; const now = new Date("2026-07-22T18:00:00.000Z"); const database: Database = Object.create(null); +const postgresDialect = new PgDialect(); function event( sequence: number, @@ -1128,6 +1130,71 @@ describe("ProcessingRepository", () => { expect(alerts.alerts).toEqual([]); }); + it("alerts only for the newest current failed operation when older failed operations share the dataset", async () => { + const newerOperationId = "10000000-0000-4000-8000-000000000051"; + const olderOperationId = "10000000-0000-4000-8000-000000000052"; + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + id: newerOperationId, + createdAt: new Date("2026-07-22T17:40:00.000Z"), + events: [ + event(1, { + operationId: newerOperationId, + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T17:50:00.000Z"), + }), + ], + }), + operation({ + id: olderOperationId, + createdAt: new Date("2026-07-22T16:40:00.000Z"), + events: [ + event(1, { + operationId: olderOperationId, + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T16:50:00.000Z"), + }), + ], + }), + ]); + mockDeriveProcessingState + .mockReturnValueOnce({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: new Date("2026-07-22T17:50:00.000Z"), + }, + ], + }) + .mockReturnValueOnce({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: new Date("2026-07-22T16:50:00.000Z"), + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const alerts = await repository.alerts(); + + expect(alerts.alerts).toHaveLength(1); + expect(alerts.alerts[0]?.id).toBe(`${newerOperationId}:activity`); + expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:50:00.000Z"); + }); + it("does not alert for resolved or in-progress datasets", async () => { mockListScopedProcessingOperations.mockResolvedValue([operation()]); const repository = new ProcessingRepository(database, userId); @@ -1310,6 +1377,19 @@ describe("ProcessingRepository", () => { expect(mockExecuteWithSchema).toHaveBeenCalledTimes(3); }); + it("scopes dismissal inserts to operations owned by the authenticated user", async () => { + mockExecuteWithSchema.mockResolvedValueOnce([{ operation_id: operationId }]); + const repository = new ProcessingRepository(database, userId); + + await repository.dismiss(operationId); + + const compiledQuery = postgresDialect.sqlToQuery(mockExecuteWithSchema.mock.calls[0]?.[2]); + expect(compiledQuery.sql).toContain("operation.user_id ="); + expect(compiledQuery.params).toEqual( + expect.arrayContaining([userId, operationId, userId]), + ); + }); + it.each([ "10000000-0000-4000-8000-000000000091", "10000000-0000-4000-8000-000000000092", diff --git a/packages/server/src/repositories/processing-repository.ts b/packages/server/src/repositories/processing-repository.ts index 4ccc2c7e34..293d037d8e 100644 --- a/packages/server/src/repositories/processing-repository.ts +++ b/packages/server/src/repositories/processing-repository.ts @@ -414,6 +414,14 @@ export class ProcessingRepository { async alerts(): Promise { const snapshot = await this.status({}); + const currentOperationIdsByDataset = new Map(); + for (const operation of snapshot.operations) { + for (const datasetKey of operation.datasets) { + if (!currentOperationIdsByDataset.has(datasetKey)) { + currentOperationIdsByDataset.set(datasetKey, operation.id); + } + } + } const alertableDatasets = new Map( snapshot.datasets .filter((dataset) => dataset.status === "failed" || dataset.status === "blocked") @@ -422,6 +430,7 @@ export class ProcessingRepository { const alerts = snapshot.operations.flatMap((operation) => { if (operation.dismissed) return []; const groupedDatasets = operation.datasets + .filter((datasetKey) => currentOperationIdsByDataset.get(datasetKey) === operation.id) .map((datasetKey) => alertableDatasets.get(datasetKey)) .filter((dataset): dataset is ProcessingStatusDataset => dataset !== undefined); return groupedDatasets.length === 0 ? [] : [buildProcessingAlert(groupedDatasets, operation)]; From 8e3b6c486fe573db0b681b4b16ae8a90de728402 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 15:41:53 -0700 Subject: [PATCH 15/46] feat: expose processing alert dismissal --- .../server/src/routers/processing.test.ts | 193 +++++++++++++++++- packages/server/src/routers/processing.ts | 18 +- 2 files changed, 204 insertions(+), 7 deletions(-) diff --git a/packages/server/src/routers/processing.test.ts b/packages/server/src/routers/processing.test.ts index dddb85c120..ebd156743d 100644 --- a/packages/server/src/routers/processing.test.ts +++ b/packages/server/src/routers/processing.test.ts @@ -1,18 +1,36 @@ +import { TRPCError } from "@trpc/server"; +import { queryCache } from "dofek/lib/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestCallerFactory } from "./test-helpers.ts"; -const { mockAlerts, mockDataQuality, mockEnsureProvidersRegistered, mockHistory, mockStatus } = - vi.hoisted(() => ({ +const { + mockAlerts, + mockDataQuality, + mockDismiss, + mockEnsureProvidersRegistered, + mockHistory, + mockStatus, +} = vi.hoisted(() => ({ mockAlerts: vi.fn(), mockDataQuality: vi.fn(), + mockDismiss: vi.fn(), mockEnsureProvidersRegistered: vi.fn(), mockHistory: vi.fn(), mockStatus: vi.fn(), })); +vi.mock("dofek/lib/cache", () => ({ + queryCache: { + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }, +})); + vi.mock("../repositories/processing-repository.ts", () => ({ ProcessingRepository: class { alerts = mockAlerts; + dismiss = mockDismiss; status = mockStatus; history = mockHistory; }, @@ -35,6 +53,9 @@ describe("processingRouter", () => { beforeEach(() => { vi.clearAllMocks(); mockEnsureProvidersRegistered.mockResolvedValue(undefined); + vi.mocked(queryCache.get).mockResolvedValue(undefined); + vi.mocked(queryCache.set).mockResolvedValue(undefined); + vi.mocked(queryCache.invalidateByPrefix).mockResolvedValue(undefined); }); it("validates the processing history response at runtime", async () => { @@ -84,10 +105,11 @@ describe("processingRouter", () => { generatedAt: "2026-07-22T12:00:00.000Z", alerts: [ { - id: "10000000-0000-4000-8000-000000000002:providers", + id: "10000000-0000-4000-8000-000000000002", providerId: "garmin", providerLabel: "Garmin", - datasetKey: "providers", + datasetKeys: ["providers"], + datasetLabels: ["Providers"], occurredAt: "2026-07-22T11:59:00.000Z", title: "Garmin summary wasn’t updated", message: "Your Garmin data is still available.", @@ -104,11 +126,148 @@ describe("processingRouter", () => { expect.objectContaining({ providerLabel: "Garmin", action: "retry_sync", + datasetKeys: ["providers"], }), ], }); }); + it("exposes grouped alert fields through the runtime output schema", async () => { + mockAlerts.mockResolvedValue({ + generatedAt: "2026-07-22T12:00:00.000Z", + alerts: [ + { + id: "10000000-0000-4000-8000-000000000002", + providerId: "garmin", + providerLabel: "Garmin", + datasetKeys: ["providers", "activity"], + datasetLabels: ["Providers", "Activities"], + occurredAt: "2026-07-22T11:59:00.000Z", + title: "Garmin needs attention", + message: "We grouped related Garmin failures into one alert.", + action: "retry_sync", + actionLabel: "Retry Garmin sync", + }, + ], + }); + const caller = createCaller({ db: {}, userId, timezone: "UTC" }); + + await expect(caller.alerts()).resolves.toEqual({ + generatedAt: "2026-07-22T12:00:00.000Z", + alerts: [ + { + id: "10000000-0000-4000-8000-000000000002", + providerId: "garmin", + providerLabel: "Garmin", + datasetKeys: ["providers", "activity"], + datasetLabels: ["Providers", "Activities"], + occurredAt: "2026-07-22T11:59:00.000Z", + title: "Garmin needs attention", + message: "We grouped related Garmin failures into one alert.", + action: "retry_sync", + actionLabel: "Retry Garmin sync", + }, + ], + }); + }); + + it("exposes failed dataset timestamps and operation dismissal fields through the runtime output schema", async () => { + mockStatus.mockResolvedValue({ + generatedAt: "2026-07-22T12:00:00.000Z", + scope: { + providerId: "garmin", + datasets: ["activity"], + }, + overallStatus: "failed", + datasets: [ + { + key: "activity", + label: "Activities", + status: "failed", + currentStage: "analytics", + progressPercentage: 80, + lastAdvancedAt: "2026-07-22T11:58:00.000Z", + lastReadyAt: null, + lastFailedAt: "2026-07-22T11:58:30.000Z", + }, + ], + operations: [ + { + id: "10000000-0000-4000-8000-000000000002", + providerId: "garmin", + kind: "provider_sync", + createdAt: "2026-07-22T11:55:00.000Z", + status: "failed", + dismissed: false, + errorMessage: "Garmin analytics refresh failed.", + datasets: ["activity"], + timeline: [ + { + sequence: 1, + stage: "analytics", + status: "failed", + datasetKey: "activity", + outputPath: null, + occurredAt: "2026-07-22T11:58:30.000Z", + progressPercentage: 80, + message: "Analytics build failed.", + errorCode: "ANALYTICS_FAILED", + errorMessage: "Garmin analytics refresh failed.", + }, + ], + }, + ], + }); + const caller = createCaller({ db: {}, userId, timezone: "UTC" }); + + await expect(caller.status({})).resolves.toEqual({ + generatedAt: "2026-07-22T12:00:00.000Z", + scope: { + providerId: "garmin", + datasets: ["activity"], + }, + overallStatus: "failed", + datasets: [ + { + key: "activity", + label: "Activities", + status: "failed", + currentStage: "analytics", + progressPercentage: 80, + lastAdvancedAt: "2026-07-22T11:58:00.000Z", + lastReadyAt: null, + lastFailedAt: "2026-07-22T11:58:30.000Z", + }, + ], + operations: [ + { + id: "10000000-0000-4000-8000-000000000002", + providerId: "garmin", + kind: "provider_sync", + createdAt: "2026-07-22T11:55:00.000Z", + status: "failed", + dismissed: false, + errorMessage: "Garmin analytics refresh failed.", + datasets: ["activity"], + timeline: [ + { + sequence: 1, + stage: "analytics", + status: "failed", + datasetKey: "activity", + outputPath: null, + occurredAt: "2026-07-22T11:58:30.000Z", + progressPercentage: 80, + message: "Analytics build failed.", + errorCode: "ANALYTICS_FAILED", + errorMessage: "Garmin analytics refresh failed.", + }, + ], + }, + ], + }); + }); + it("returns the server-owned data quality overview", async () => { mockDataQuality.mockResolvedValue({ generatedAt: "2026-07-22T12:00:00.000Z", @@ -195,4 +354,30 @@ describe("processingRouter", () => { expect(mockEnsureProvidersRegistered).toHaveBeenCalledOnce(); expect(mockDataQuality).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)); }); + + it("dismisses a processing alert and invalidates cached processing queries", async () => { + const operationId = "10000000-0000-4000-8000-000000000002"; + mockDismiss.mockResolvedValue({ dismissed: true }); + const caller = createCaller({ db: {}, userId, timezone: "UTC" }); + + await expect(caller.dismiss({ operationId })).resolves.toEqual({ dismissed: true }); + expect(mockDismiss).toHaveBeenCalledWith(operationId); + expect(queryCache.invalidateByPrefix).toHaveBeenCalledWith(`${userId}:processing.`); + }); + + it("propagates repository not-found errors from dismiss", async () => { + const operationId = "10000000-0000-4000-8000-000000000002"; + mockDismiss.mockRejectedValue( + new TRPCError({ + code: "NOT_FOUND", + message: "Processing operation not found.", + }), + ); + const caller = createCaller({ db: {}, userId, timezone: "UTC" }); + + await expect(caller.dismiss({ operationId })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Processing operation not found.", + }); + }); }); diff --git a/packages/server/src/routers/processing.ts b/packages/server/src/routers/processing.ts index ce74852316..a50000942b 100644 --- a/packages/server/src/routers/processing.ts +++ b/packages/server/src/routers/processing.ts @@ -1,5 +1,6 @@ import { PROCESSING_ALERT_ACTIONS } from "@dofek/providers/processing-alerts"; import { processingPollInterval } from "@dofek/providers/processing-status"; +import { queryCache } from "dofek/lib/cache"; import { PROCESSING_DATASET_KEYS, PROCESSING_OUTPUT_PATHS, @@ -15,7 +16,7 @@ import { endDateSchema } from "../lib/date-window.ts"; import { dateStringSchema, timestampStringSchema } from "../lib/typed-sql.ts"; import { DataQualityRepository } from "../repositories/data-quality-repository.ts"; import { ProcessingRepository } from "../repositories/processing-repository.ts"; -import { CacheTTL, cachedProtectedQuery, router } from "../trpc.ts"; +import { CacheTTL, cachedProtectedQuery, protectedProcedure, router } from "../trpc.ts"; import { ensureProvidersRegistered } from "./sync-helpers.ts"; const datasetKeySchema = z.enum(PROCESSING_DATASET_KEYS); @@ -56,6 +57,7 @@ const statusOutputSchema = z.object({ progressPercentage: z.number().int().min(0).max(100).nullable(), lastAdvancedAt: z.string().datetime().nullable(), lastReadyAt: z.string().datetime().nullable(), + lastFailedAt: z.string().datetime().nullable(), }), ), operations: z.array( @@ -65,6 +67,8 @@ const statusOutputSchema = z.object({ kind: operationKindSchema, createdAt: z.string().datetime(), status: derivedStatusSchema, + dismissed: z.boolean(), + errorMessage: z.string().nullable(), datasets: z.array(datasetKeySchema), timeline: z.array(timelineEventSchema), }), @@ -74,10 +78,11 @@ const alertsOutputSchema = z.object({ generatedAt: z.string().datetime(), alerts: z.array( z.object({ - id: z.string().min(1), + id: z.uuid(), providerId: z.string().nullable(), providerLabel: z.string().nullable(), - datasetKey: datasetKeySchema, + datasetKeys: z.array(datasetKeySchema).min(1), + datasetLabels: z.array(z.string().min(1)).min(1), occurredAt: z.string().datetime(), title: z.string().min(1), message: z.string().min(1), @@ -133,6 +138,13 @@ export const processingRouter = router({ alerts: cachedProtectedQuery({ maxAge: processingPollInterval("failed") }) .output(alertsOutputSchema) .query(({ ctx }) => new ProcessingRepository(ctx.db, ctx.userId).alerts()), + dismiss: protectedProcedure + .input(z.object({ operationId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + const result = await new ProcessingRepository(ctx.db, ctx.userId).dismiss(input.operationId); + await queryCache.invalidateByPrefix(`${ctx.userId}:processing.`); + return result; + }), status: cachedProtectedQuery({ maxAge: processingPollInterval("active") }) .input(statusInputSchema) .output(statusOutputSchema) From ab7db82e1db63f97c431aed1ec38e74fafb36607 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 17:43:27 -0700 Subject: [PATCH 16/46] docs: align grouped alert contract --- docs/superpowers/plans/2026-08-08-processing-status-alerts.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-08-processing-status-alerts.md b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md index fd38d00d68..12ad992ca2 100644 --- a/docs/superpowers/plans/2026-08-08-processing-status-alerts.md +++ b/docs/superpowers/plans/2026-08-08-processing-status-alerts.md @@ -116,6 +116,7 @@ git commit -m "feat: add processing alert dismissals" **Files:** - Modify: `packages/server/src/repositories/processing-repository.ts` - Modify: `packages/server/src/repositories/processing-repository.test.ts` +- Modify: `packages/providers-meta/src/processing-alerts.ts` when aligning the shared grouped-alert type with the live router contract **Interfaces:** - `ProcessingStatusDataset` gains `lastFailedAt: string | null`. @@ -123,6 +124,7 @@ git commit -m "feat: add processing alert dismissals" - `ProcessingRepository.dismiss(operationId: string): Promise<{ dismissed: true }>` inserts an idempotent dismissal only for an operation owned by `#userId`; an unknown or foreign operation throws a not-found error. - `ProcessingRepository.status()` reads dismissal rows only for the scoped operations and returns dismissal state on each operation. - `ProcessingRepository.alerts()` returns at most one grouped `ProcessingAlert` per current failed/blocked operation and excludes dismissed operations. +- The grouped `ProcessingAlert` returned by the repository uses the operation UUID as `id` and includes `datasetKeys` and `datasetLabels`; this shared type alignment is part of this task so the Task 3 runtime router contract cannot be false-green against mocked repository output. - [ ] **Step 1: Write failing repository tests** @@ -179,7 +181,7 @@ Expected: PASS, including all existing status/history/alert behavior and the new - [ ] **Step 5: Commit** ```bash -git add packages/server/src/repositories/processing-repository.ts packages/server/src/repositories/processing-repository.test.ts +git add packages/server/src/repositories/processing-repository.ts packages/server/src/repositories/processing-repository.test.ts packages/providers-meta/src/processing-alerts.ts git commit -m "feat: derive processing failure timestamps" ``` From 0d72809e4244ccc726cb8a05a86d6cc3dd404895 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 17:49:09 -0700 Subject: [PATCH 17/46] fix: align processing alert contract --- packages/providers-meta/src/processing-alerts.ts | 2 ++ .../repositories/processing-repository.test.ts | 15 +++++++++++---- .../src/repositories/processing-repository.ts | 8 +++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/providers-meta/src/processing-alerts.ts b/packages/providers-meta/src/processing-alerts.ts index df838b7571..b08f4a7041 100644 --- a/packages/providers-meta/src/processing-alerts.ts +++ b/packages/providers-meta/src/processing-alerts.ts @@ -20,6 +20,8 @@ export interface ProcessingAlert { providerId: string | null; providerLabel: string | null; datasetKey: string; + datasetKeys: string[]; + datasetLabels: string[]; occurredAt: string; title: string; message: string; diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index fdd3c49a0d..76f1678289 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -607,11 +607,12 @@ describe("ProcessingRepository", () => { generatedAt: "2026-07-22T18:00:00.000Z", alerts: [ { - id: `${operationId}:providers`, + id: operationId, providerId: "garmin", providerLabel: "Garmin", datasetKey: "providers", datasetKeys: ["providers"], + datasetLabels: ["Data sources"], occurredAt: "2026-07-22T18:00:00.000Z", title: "Garmin summary wasn’t updated", message: @@ -877,11 +878,12 @@ describe("ProcessingRepository", () => { generatedAt: "2026-07-22T18:00:00.000Z", alerts: [ { - id: `${operationId}:activity`, + id: operationId, providerId: null, providerLabel: null, datasetKey: "activity", datasetKeys: ["activity"], + datasetLabels: ["Activities"], occurredAt: latestFailureAt.toISOString(), title: "Activities wasn’t updated", message: @@ -961,11 +963,12 @@ describe("ProcessingRepository", () => { generatedAt: "2026-07-22T18:00:00.000Z", alerts: [ { - id: `${operationId}:activity`, + id: operationId, providerId: "garmin", providerLabel: "Garmin", datasetKey: "activity", datasetKeys: ["activity"], + datasetLabels: ["Activities"], occurredAt: "2026-07-22T18:00:00.000Z", title: "Activities wasn’t updated", message: @@ -1091,7 +1094,9 @@ describe("ProcessingRepository", () => { const alerts = await repository.alerts(); expect(alerts.alerts).toHaveLength(1); + expect(alerts.alerts[0]?.id).toBe(operationId); expect(alerts.alerts[0]?.datasetKeys).toEqual(["activity", "recovery", "sleep"]); + expect(alerts.alerts[0]?.datasetLabels).toEqual(["Activities", "Recovery", "Sleep"]); expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:50:00.000Z"); }); @@ -1191,7 +1196,9 @@ describe("ProcessingRepository", () => { const alerts = await repository.alerts(); expect(alerts.alerts).toHaveLength(1); - expect(alerts.alerts[0]?.id).toBe(`${newerOperationId}:activity`); + expect(alerts.alerts[0]?.id).toBe(newerOperationId); + expect(alerts.alerts[0]?.datasetKeys).toEqual(["activity"]); + expect(alerts.alerts[0]?.datasetLabels).toEqual(["Activities"]); expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:50:00.000Z"); }); diff --git a/packages/server/src/repositories/processing-repository.ts b/packages/server/src/repositories/processing-repository.ts index 293d037d8e..8956f5cb1a 100644 --- a/packages/server/src/repositories/processing-repository.ts +++ b/packages/server/src/repositories/processing-repository.ts @@ -76,6 +76,7 @@ export interface ProcessingStatusSnapshot { interface ServerProcessingAlert extends Omit { datasetKey: ProcessingDatasetKey; datasetKeys: ProcessingDatasetKey[]; + datasetLabels: string[]; } export interface ProcessingAlertsSnapshot { @@ -169,7 +170,8 @@ function buildProcessingAlert( .find((value): value is string => value !== null) ?? operation.createdAt; const datasetKeys = datasets.map((dataset) => dataset.key); - const alertId = `${operation.id}:${datasetKeys.join(",")}`; + const datasetLabels = datasets.map((dataset) => dataset.label); + const alertId = operation.id; if (operation.kind === "file_import") { const importedFile = sourceLabel ? `the ${sourceLabel} file` : "your file"; @@ -179,6 +181,7 @@ function buildProcessingAlert( providerLabel: sourceLabel, datasetKey: primaryDataset.key, datasetKeys, + datasetLabels, occurredAt, title: failedEvent?.stage === "ingest" @@ -205,6 +208,7 @@ function buildProcessingAlert( providerLabel: sourceLabel, datasetKey: primaryDataset.key, datasetKeys, + datasetLabels, occurredAt, title: `${sourceLabel} couldn’t sync`, message: `Dofek couldn’t get the latest data from ${sourceLabel}. Reconnect ${sourceLabel}, then start the sync again.`, @@ -219,6 +223,7 @@ function buildProcessingAlert( providerLabel: sourceLabel, datasetKey: primaryDataset.key, datasetKeys, + datasetLabels, occurredAt, title: `${sourceLabel} ${subject} ${titleSuffix}`, message: @@ -236,6 +241,7 @@ function buildProcessingAlert( providerLabel: sourceLabel, datasetKey: primaryDataset.key, datasetKeys, + datasetLabels, occurredAt, title: `${titleLabel} ${titleSuffix}`, message: `Dofek couldn’t update ${subject}. Your existing data is still available. Contact support for help.`, From bb8cb15c09bff871672f8fe922dd6b89a63da9c7 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 17:55:11 -0700 Subject: [PATCH 18/46] fix: remove legacy processing alert key --- packages/providers-meta/src/processing-alerts.ts | 1 - .../server/src/repositories/processing-repository.test.ts | 5 +---- packages/server/src/repositories/processing-repository.ts | 8 +------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/providers-meta/src/processing-alerts.ts b/packages/providers-meta/src/processing-alerts.ts index b08f4a7041..6715f7a62b 100644 --- a/packages/providers-meta/src/processing-alerts.ts +++ b/packages/providers-meta/src/processing-alerts.ts @@ -19,7 +19,6 @@ export interface ProcessingAlert { id: string; providerId: string | null; providerLabel: string | null; - datasetKey: string; datasetKeys: string[]; datasetLabels: string[]; occurredAt: string; diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index 76f1678289..7079f7dfe0 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -610,7 +610,6 @@ describe("ProcessingRepository", () => { id: operationId, providerId: "garmin", providerLabel: "Garmin", - datasetKey: "providers", datasetKeys: ["providers"], datasetLabels: ["Data sources"], occurredAt: "2026-07-22T18:00:00.000Z", @@ -881,7 +880,6 @@ describe("ProcessingRepository", () => { id: operationId, providerId: null, providerLabel: null, - datasetKey: "activity", datasetKeys: ["activity"], datasetLabels: ["Activities"], occurredAt: latestFailureAt.toISOString(), @@ -966,7 +964,6 @@ describe("ProcessingRepository", () => { id: operationId, providerId: "garmin", providerLabel: "Garmin", - datasetKey: "activity", datasetKeys: ["activity"], datasetLabels: ["Activities"], occurredAt: "2026-07-22T18:00:00.000Z", @@ -1025,7 +1022,7 @@ describe("ProcessingRepository", () => { await expect(repository.alerts()).resolves.toEqual({ generatedAt: "2026-07-22T18:00:00.000Z", - alerts: [expect.objectContaining({ datasetKey: "activity", action: "retry_import" })], + alerts: [expect.objectContaining({ datasetKeys: ["activity"], action: "retry_import" })], }); }); diff --git a/packages/server/src/repositories/processing-repository.ts b/packages/server/src/repositories/processing-repository.ts index 8956f5cb1a..24d2bfde03 100644 --- a/packages/server/src/repositories/processing-repository.ts +++ b/packages/server/src/repositories/processing-repository.ts @@ -73,10 +73,8 @@ export interface ProcessingStatusSnapshot { operations: ProcessingStatusOperation[]; } -interface ServerProcessingAlert extends Omit { - datasetKey: ProcessingDatasetKey; +interface ServerProcessingAlert extends Omit { datasetKeys: ProcessingDatasetKey[]; - datasetLabels: string[]; } export interface ProcessingAlertsSnapshot { @@ -179,7 +177,6 @@ function buildProcessingAlert( id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: primaryDataset.key, datasetKeys, datasetLabels, occurredAt, @@ -206,7 +203,6 @@ function buildProcessingAlert( id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: primaryDataset.key, datasetKeys, datasetLabels, occurredAt, @@ -221,7 +217,6 @@ function buildProcessingAlert( id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: primaryDataset.key, datasetKeys, datasetLabels, occurredAt, @@ -239,7 +234,6 @@ function buildProcessingAlert( id: alertId, providerId: operation.providerId, providerLabel: sourceLabel, - datasetKey: primaryDataset.key, datasetKeys, datasetLabels, occurredAt, From 0bc6447a1e9899daf2df9b5aa571e3b90f5479ee Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:06:05 -0700 Subject: [PATCH 19/46] feat: group processing failure presentation --- .../src/processing-status.test.ts | 191 ++++++++++++++++++ .../providers-meta/src/processing-status.ts | 79 ++++++++ 2 files changed, 270 insertions(+) diff --git a/packages/providers-meta/src/processing-status.test.ts b/packages/providers-meta/src/processing-status.test.ts index 6206f2b406..b781693164 100644 --- a/packages/providers-meta/src/processing-status.test.ts +++ b/packages/providers-meta/src/processing-status.test.ts @@ -3,6 +3,7 @@ import { processingAggregateProgress, processingDatasetErrorMessage, processingDatasetStatusLabel, + processingFailureGroups, processingHeading, processingPollInterval, processingStatusMessage, @@ -10,6 +11,9 @@ import { } from "./processing-status.ts"; describe("processing status presentation", () => { + const firstOperationId = "10000000-0000-4000-8000-000000000001"; + const secondOperationId = "10000000-0000-4000-8000-000000000002"; + it("gives failures and delays actionable copy", () => { expect(processingHeading("failed")).toBe("Your data update didn’t finish"); expect(processingStatusMessage({ status: "failed", errorMessage: "Reconnect WHOOP." })).toBe( @@ -166,6 +170,193 @@ describe("processing status presentation", () => { ).toBe(80); }); + it("groups current failed datasets by operation in server dataset order", () => { + const groups = processingFailureGroups({ + datasets: [ + { + key: "activity", + label: "Activities", + status: "failed", + lastFailedAt: "2026-07-22T14:00:00.000Z", + lastReadyAt: "2026-07-21T10:00:00.000Z", + }, + { + key: "recovery", + label: "Recovery", + status: "blocked", + lastFailedAt: "2026-07-22T16:00:00.000Z", + lastReadyAt: "2026-07-21T12:00:00.000Z", + }, + { + key: "sleep", + label: "Sleep", + status: "failed", + lastFailedAt: "2026-07-22T15:00:00.000Z", + lastReadyAt: "2026-07-21T11:00:00.000Z", + }, + ], + operations: [ + { + id: firstOperationId, + providerId: "whoop", + status: "failed", + datasets: ["sleep", "activity", "recovery"], + dismissed: false, + errorMessage: "Reconnect WHOOP.", + }, + ], + }); + + expect(groups).toEqual([ + { + operationId: firstOperationId, + providerLabel: "WHOOP (Cloud)", + datasetLabels: ["Activities", "Recovery", "Sleep"], + status: "failed", + failedAt: "2026-07-22T16:00:00.000Z", + lastReadyAt: "2026-07-21T12:00:00.000Z", + errorMessage: "Reconnect WHOOP.", + dismissed: false, + }, + ]); + }); + + it("does not expose dismissed or later-ready operation groups", () => { + expect( + processingFailureGroups({ + datasets: [ + { + key: "activity", + label: "Activities", + status: "failed", + lastFailedAt: "2026-07-22T14:00:00.000Z", + lastReadyAt: null, + }, + ], + operations: [ + { + id: firstOperationId, + providerId: "garmin", + status: "failed", + datasets: ["activity"], + dismissed: true, + errorMessage: "Reconnect Garmin.", + }, + ], + }), + ).toEqual([]); + + expect( + processingFailureGroups({ + datasets: [ + { + key: "activity", + label: "Activities", + status: "ready", + lastFailedAt: "2026-07-22T14:00:00.000Z", + lastReadyAt: "2026-07-22T15:00:00.000Z", + }, + ], + operations: [ + { + id: firstOperationId, + providerId: "garmin", + status: "failed", + datasets: ["activity"], + dismissed: false, + errorMessage: "Old failure.", + }, + ], + }), + ).toEqual([]); + }); + + it("keeps separate failed operations separate", () => { + expect( + processingFailureGroups({ + datasets: [ + { + key: "activity", + label: "Activities", + status: "failed", + lastFailedAt: "2026-07-22T14:00:00.000Z", + lastReadyAt: "2026-07-21T10:00:00.000Z", + }, + { + key: "sleep", + label: "Sleep", + status: "blocked", + lastFailedAt: "2026-07-22T15:00:00.000Z", + lastReadyAt: "2026-07-21T11:00:00.000Z", + }, + ], + operations: [ + { + id: firstOperationId, + providerId: "garmin", + status: "failed", + datasets: ["activity"], + dismissed: false, + errorMessage: "Activity failed.", + }, + { + id: secondOperationId, + providerId: "whoop", + status: "blocked", + datasets: ["sleep"], + dismissed: false, + errorMessage: "Sleep blocked.", + }, + ], + }), + ).toEqual([ + expect.objectContaining({ + operationId: firstOperationId, + datasetLabels: ["Activities"], + status: "failed", + errorMessage: "Activity failed.", + }), + expect.objectContaining({ + operationId: secondOperationId, + datasetLabels: ["Sleep"], + status: "blocked", + errorMessage: "Sleep blocked.", + }), + ]); + }); + + it("preserves a missing last-ready timestamp", () => { + expect( + processingFailureGroups({ + datasets: [ + { + key: "providers", + label: "Providers", + status: "blocked", + lastFailedAt: "2026-07-22T14:00:00.000Z", + lastReadyAt: null, + }, + ], + operations: [ + { + id: firstOperationId, + providerId: null, + status: "blocked", + datasets: ["providers"], + dismissed: false, + errorMessage: null, + }, + ], + }), + ).toEqual([ + expect.objectContaining({ + operationId: firstOperationId, + providerLabel: null, + lastReadyAt: null, + }), + ]); + }); + it("uses the latest matching failed event as the dataset error", () => { expect( processingDatasetErrorMessage( diff --git a/packages/providers-meta/src/processing-status.ts b/packages/providers-meta/src/processing-status.ts index 2aa69a6c52..3093483e70 100644 --- a/packages/providers-meta/src/processing-status.ts +++ b/packages/providers-meta/src/processing-status.ts @@ -160,6 +160,85 @@ export function processingAggregateProgress( return Math.min(...progressValues); } +interface ProcessingFailureDataset { + key: string; + label: string; + status: ProcessingDisplayStatus; + lastFailedAt: string | null; + lastReadyAt: string | null; +} + +interface ProcessingFailureOperation { + id: string; + providerId: string | null; + status: ProcessingDisplayStatus; + datasets: readonly string[]; + dismissed: boolean; + errorMessage: string | null; +} + +export interface ProcessingFailureGroup { + operationId: string; + providerLabel: string | null; + datasetLabels: string[]; + status: "blocked" | "failed"; + failedAt: string | null; + lastReadyAt: string | null; + errorMessage: string | null; + dismissed: boolean; +} + +function isFailureStatus(status: ProcessingDisplayStatus): status is "blocked" | "failed" { + return status === "blocked" || status === "failed"; +} + +function latestTimestamp(values: readonly (string | null)[]): string | null { + return ( + values + .filter((value): value is string => value !== null) + .sort((left, right) => right.localeCompare(left))[0] ?? null + ); +} + +export function processingFailureGroups(input: { + datasets: readonly ProcessingFailureDataset[]; + operations: readonly ProcessingFailureOperation[]; +}): ProcessingFailureGroup[] { + const currentOperationIdsByDataset = new Map(); + for (const operation of input.operations) { + for (const datasetKey of operation.datasets) { + if (!currentOperationIdsByDataset.has(datasetKey)) { + currentOperationIdsByDataset.set(datasetKey, operation.id); + } + } + } + + const alertableDatasets = input.datasets.filter((dataset) => isFailureStatus(dataset.status)); + return input.operations.flatMap((operation) => { + if (operation.dismissed || !isFailureStatus(operation.status)) return []; + const operationDatasetKeys = new Set(operation.datasets); + const groupedDatasets = alertableDatasets.filter( + (dataset) => + operationDatasetKeys.has(dataset.key) && + currentOperationIdsByDataset.get(dataset.key) === operation.id, + ); + if (groupedDatasets.length === 0) return []; + + return [ + { + operationId: operation.id, + providerLabel: operation.providerId ? providerLabel(operation.providerId) : null, + datasetLabels: groupedDatasets.map((dataset) => dataset.label), + status: operation.status, + failedAt: latestTimestamp(groupedDatasets.map((dataset) => dataset.lastFailedAt)), + lastReadyAt: latestTimestamp(groupedDatasets.map((dataset) => dataset.lastReadyAt)), + errorMessage: operation.errorMessage, + dismissed: operation.dismissed, + }, + ]; + }); +} + interface ProcessingErrorEvent { datasetKey: string | null; status: string; From b728c8d40ba9f8e9b42da6ce3e8732025b067f05 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:20:32 -0700 Subject: [PATCH 20/46] feat: clarify web processing failures --- .../ProcessingStatusWidget.stories.tsx | 6 + .../ProcessingStatusWidget.test.tsx | 270 +++++++++++++++--- .../src/components/ProcessingStatusWidget.tsx | 74 ++++- packages/web/src/pages/AlertsPage.stories.tsx | 27 +- packages/web/src/pages/AlertsPage.test.tsx | 158 +++++++--- packages/web/src/pages/AlertsPage.tsx | 43 ++- 6 files changed, 475 insertions(+), 103 deletions(-) diff --git a/packages/web/src/components/ProcessingStatusWidget.stories.tsx b/packages/web/src/components/ProcessingStatusWidget.stories.tsx index d089dedad3..93df6851c3 100644 --- a/packages/web/src/components/ProcessingStatusWidget.stories.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.stories.tsx @@ -17,6 +17,7 @@ const activeSnapshot: ProcessingStatusSnapshot = { progressPercentage: 60, lastAdvancedAt: "2026-07-22T11:59:00.000Z", lastReadyAt: "2026-07-21T12:00:00.000Z", + lastFailedAt: null, }, ], operations: [ @@ -27,6 +28,8 @@ const activeSnapshot: ProcessingStatusSnapshot = { createdAt: "2026-07-22T11:58:00.000Z", status: "active", datasets: ["activity"], + dismissed: false, + errorMessage: null, timeline: [ { sequence: 1, @@ -138,12 +141,15 @@ export const Failed: Story = { ...activeDataset, status: "failed", progressPercentage: null, + lastFailedAt: "2026-07-22T12:05:00.000Z", }, ], operations: [ { ...activeOperation, status: "failed", + dismissed: false, + errorMessage: "Reconnect Garmin, then start the sync again.", timeline: [ { ...activeTimelineEvent, diff --git a/packages/web/src/components/ProcessingStatusWidget.test.tsx b/packages/web/src/components/ProcessingStatusWidget.test.tsx index 3dc786c233..8947bc705e 100644 --- a/packages/web/src/components/ProcessingStatusWidget.test.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.test.tsx @@ -1,61 +1,138 @@ /** @vitest-environment jsdom */ -import { render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ProcessingStatusSnapshot, ProcessingStatusWidget, } from "./ProcessingStatusWidget.tsx"; +const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => ({ + mockDismissOperation: vi.fn(), + mockDismissState: { + error: null, + isPending: false, + } satisfies { error: Error | null; isPending: boolean }, + mockInvalidateStatus: vi.fn(), +})); + +vi.mock("../lib/trpc.ts", () => ({ + trpc: { + processing: { + dismiss: { + useMutation: (options: { onSuccess?: () => Promise | void }) => ({ + error: mockDismissState.error, + isPending: mockDismissState.isPending, + mutate: (input: { operationId: string }) => { + mockDismissOperation(input); + if (!mockDismissState.error) { + void options.onSuccess?.(); + } + }, + }), + }, + }, + useUtils: () => ({ + processing: { + status: { + invalidate: mockInvalidateStatus, + }, + }, + }), + }, +})); + +const operationId = "00000000-0000-4000-8000-000000001852"; +const activityDataset = { + key: "activity", + label: "Activities", + status: "active" as const, + currentStage: "analytics" as const, + progressPercentage: 60, + lastAdvancedAt: "2026-07-22T11:59:00.000Z", + lastReadyAt: "2026-07-21T12:00:00.000Z", + lastFailedAt: null, +}; +const timelineEvent = { + sequence: 1, + stage: "ingest" as const, + status: "succeeded", + datasetKey: "activity", + outputPath: null, + occurredAt: "2026-07-22T11:58:30.000Z", + progressPercentage: 100, + message: null, + errorCode: null, + errorMessage: null, +}; +const operation = { + id: operationId, + providerId: "garmin", + kind: "provider_sync", + createdAt: "2026-07-22T11:58:00.000Z", + status: "active" as const, + datasets: ["activity"], + dismissed: false, + errorMessage: null, + timeline: [timelineEvent], +}; const snapshot: ProcessingStatusSnapshot = { generatedAt: "2026-07-22T12:00:00.000Z", scope: { providerId: "garmin", datasets: ["activity"] }, overallStatus: "active", - datasets: [ - { - key: "activity", - label: "Activities", - status: "active", - currentStage: "analytics", - progressPercentage: 60, - lastAdvancedAt: "2026-07-22T11:59:00.000Z", - lastReadyAt: "2026-07-21T12:00:00.000Z", - }, - ], - operations: [ - { - id: "00000000-0000-4000-8000-000000001852", - providerId: "garmin", - kind: "provider_sync", - createdAt: "2026-07-22T11:58:00.000Z", - status: "active", - datasets: ["activity"], - timeline: [ - { - sequence: 1, - stage: "ingest", - status: "succeeded", - datasetKey: "activity", - outputPath: null, - occurredAt: "2026-07-22T11:58:30.000Z", - progressPercentage: 100, - message: null, - errorCode: null, - errorMessage: null, - }, - ], - }, - ], + datasets: [activityDataset], + operations: [operation], }; -const activityDataset = snapshot.datasets.at(0); -if (!activityDataset) throw new Error("Expected the processing snapshot fixture to include data"); -const operation = snapshot.operations.at(0); -if (!operation) throw new Error("Expected the processing snapshot fixture to include an operation"); -const timelineEvent = operation.timeline.at(0); -if (!timelineEvent) throw new Error("Expected the processing snapshot fixture to include an event"); +const wahooDatasetLabels = [ + ["activity", "Activities"], + ["sleep", "Sleep"], + ["recovery", "Recovery"], + ["training", "Training"], + ["body", "Body"], + ["providers", "Provider summaries"], +] as const; + +function failedWahooSnapshot(overrides: Partial = {}) { + const failedDatasets = wahooDatasetLabels.map(([key, label]) => ({ + ...activityDataset, + key, + label, + status: "failed" as const, + progressPercentage: null, + lastReadyAt: "2026-07-22T16:00:00.000Z", + lastFailedAt: "2026-07-22T16:05:00.000Z", + })); + return { + ...snapshot, + generatedAt: "2026-07-22T16:10:00.000Z", + scope: { providerId: "wahoo", datasets: failedDatasets.map((dataset) => dataset.key) }, + overallStatus: "failed" as const, + datasets: failedDatasets, + operations: [ + { + ...operation, + id: "00000000-0000-4000-8000-00000000f501", + providerId: "wahoo", + status: "failed" as const, + datasets: failedDatasets.map((dataset) => dataset.key), + dismissed: false, + errorMessage: "Wahoo returned a server error. Reconnect Wahoo, then try again.", + timeline: [], + }, + ], + ...overrides, + } satisfies ProcessingStatusSnapshot; +} describe("ProcessingStatusWidget", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDismissState.error = null; + mockDismissState.isPending = false; + }); + afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("stays quiet when ready unless always visible", () => { @@ -157,6 +234,7 @@ describe("ProcessingStatusWidget", () => { ...activityDataset, status, progressPercentage: null, + lastFailedAt: "2026-07-22T13:00:00.000Z", lastReadyAt: "2026-07-22T12:00:00.000Z", }, ], @@ -164,6 +242,7 @@ describe("ProcessingStatusWidget", () => { { ...operation, status, + errorMessage: "Reconnect Garmin, then start the sync again.", timeline: [ { ...timelineEvent, @@ -179,11 +258,102 @@ describe("ProcessingStatusWidget", () => { expect(screen.getByText("Garmin sync didn’t finish")).toBeTruthy(); expect(screen.getByText("Activities")).toBeTruthy(); - expect(screen.getByText(status === "failed" ? "Failed" : "Blocked")).toBeTruthy(); - expect(screen.getByText("Last ready: 2h ago")).toBeTruthy(); + expect(screen.getByText(`${status === "failed" ? "Failed" : "Blocked"}: 1h ago`)).toBeTruthy(); + expect(screen.getByText("Last successful update: 2h ago")).toBeTruthy(); expect(screen.getByText("Reconnect Garmin, then start the sync again.")).toBeTruthy(); }); + it("groups current failed datasets by operation and offers dismissal", () => { + vi.setSystemTime(new Date("2026-08-07T16:05:00.000Z")); + + render(); + + expect(screen.getByText("Wahoo sync didn’t finish")).toBeTruthy(); + expect(screen.getAllByText("Activities")).toHaveLength(1); + expect(screen.getByText("Sleep")).toBeTruthy(); + expect(screen.getByText("Recovery")).toBeTruthy(); + expect(screen.getByText("Training")).toBeTruthy(); + expect(screen.getByText("Body")).toBeTruthy(); + expect(screen.getByText("Provider summaries")).toBeTruthy(); + expect(screen.getByText("Failed: 16d ago")).toBeTruthy(); + expect(screen.getByText("Last successful update: 16d ago")).toBeTruthy(); + expect( + screen.getByText("Wahoo returned a server error. Reconnect Wahoo, then try again."), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeTruthy(); + }); + + it("dismisses a failure group by operation and refreshes the status query", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })); + + expect(mockDismissOperation).toHaveBeenCalledWith({ + operationId: "00000000-0000-4000-8000-00000000f501", + }); + await waitFor(() => expect(mockInvalidateStatus).toHaveBeenCalledOnce()); + }); + + it("disables the dismiss button while the operation is pending", () => { + mockDismissState.isPending = true; + + render(); + + expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeDisabled(); + }); + + it("hides dismissed failure groups even when the widget is always visible", () => { + const currentSnapshot = failedWahooSnapshot(); + const currentOperation = currentSnapshot.operations[0]; + if (!currentOperation) throw new Error("Expected a current operation"); + const dismissedSnapshot = failedWahooSnapshot({ + operations: [{ ...currentOperation, dismissed: true }], + }); + + expect( + render().container.innerHTML, + ).toBe(""); + }); + + it("does not render an older failed group after the dataset is ready again", () => { + render( + , + ); + + expect(screen.queryByText("This older failure is resolved.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeNull(); + }); + + it("shows one operation-level mutation error after a dismiss attempt fails", () => { + mockDismissState.error = new Error("Could not dismiss this sync failure."); + + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("Could not dismiss this sync failure."); + expect(screen.getAllByText("Could not dismiss this sync failure.")).toHaveLength(1); + }); + it("shows every dataset and its freshness when explicitly kept visible", () => { vi.setSystemTime(new Date("2026-07-22T14:00:00.000Z")); render( @@ -256,6 +426,14 @@ describe("ProcessingStatusWidget", () => { ...activityDataset, status: "failed", progressPercentage: null, + lastFailedAt: "2026-07-22T13:00:00.000Z", + }, + ], + operations: [ + { + ...operation, + status: "failed", + errorMessage: null, }, ], }} @@ -263,7 +441,7 @@ describe("ProcessingStatusWidget", () => { ); expect(screen.getByText("Activities")).toBeTruthy(); - expect(screen.getByText("Failed")).toBeTruthy(); + expect(screen.getByText(/Failed: \d+d ago/)).toBeTruthy(); }); it("does not claim freshness for synthetic ready datasets without processing history", () => { diff --git a/packages/web/src/components/ProcessingStatusWidget.tsx b/packages/web/src/components/ProcessingStatusWidget.tsx index 2cdee745fb..c98804727d 100644 --- a/packages/web/src/components/ProcessingStatusWidget.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.tsx @@ -3,12 +3,13 @@ import { type ProcessingDisplayStage, type ProcessingDisplayStatus, processingAggregateProgress, - processingDatasetErrorMessage, processingDatasetStatusLabel, + processingFailureGroups, processingHeading, processingStatusMessage, processingTarget, } from "@dofek/providers/processing-status"; +import { trpc } from "../lib/trpc.ts"; import { RecomputeStatusIndicator } from "./RecomputeStatusIndicator.tsx"; import { SourceProcessingStatusCard } from "./SourceProcessingStatusCard.tsx"; @@ -24,6 +25,7 @@ export interface ProcessingStatusSnapshot { progressPercentage: number | null; lastAdvancedAt: string | null; lastReadyAt: string | null; + lastFailedAt: string | null; }>; operations: Array<{ id: string; @@ -32,6 +34,8 @@ export interface ProcessingStatusSnapshot { createdAt: string; status: ProcessingDisplayStatus; datasets: string[]; + dismissed: boolean; + errorMessage: string | null; timeline: Array<{ sequence: number; stage: ProcessingDisplayStage; @@ -62,6 +66,12 @@ export function ProcessingStatusWidget({ contextLabel, alwaysVisible = false, }: ProcessingStatusWidgetProps) { + const trpcUtils = trpc.useUtils(); + const dismissMutation = trpc.processing.dismiss.useMutation({ + onSuccess: async () => { + await trpcUtils.processing.status.invalidate(); + }, + }); if (loading && !data) { return (
dataset.status === "failed" || dataset.status === "blocked", ); + const failureGroups = processingFailureGroups({ + datasets: data.datasets, + operations: data.operations, + }); + const hasFailureStatus = data.overallStatus === "failed" || data.overallStatus === "blocked"; + if (hasFailureStatus && failureGroups.length === 0) { + return null; + } const datasetsWithHistory = data.datasets.filter( (dataset) => dataset.status !== "ready" || dataset.lastAdvancedAt !== null || dataset.lastReadyAt !== null, ); const visibleDatasets = alwaysVisible ? datasetsWithHistory : problemDatasets; - const datasetDetails = + const historicalDatasetDetails = visibleDatasets.length > 0 ? (
    {visibleDatasets.map((dataset) => { const lastReady = dataset.lastReadyAt ? formatRelativeTime(dataset.lastReadyAt) : null; - const datasetError = - dataset.status === "failed" || dataset.status === "blocked" - ? processingDatasetErrorMessage(data.operations, dataset.key) - : null; return (
  • @@ -124,12 +138,53 @@ export function ProcessingStatusWidget({

    {lastReady ? `Last ready: ${lastReady}` : "No completed update recorded"}

    - {datasetError ?

    {datasetError}

    : null}
  • ); })}
) : null; + const failureGroupDetails = + failureGroups.length > 0 ? ( +
    + {failureGroups.map((group) => { + const failedAt = group.failedAt ? formatRelativeTime(group.failedAt) : null; + const lastReadyAt = group.lastReadyAt ? formatRelativeTime(group.lastReadyAt) : null; + const labelPrefix = group.providerLabel ? `${group.providerLabel} sync` : "data update"; + return ( +
  • +
    +
    +
      + {group.datasetLabels.map((label) => ( +
    • {label}
    • + ))} +
    +

    + {processingDatasetStatusLabel(group.status)}: {failedAt ?? "not recorded"} +

    + {lastReadyAt ? ( +

    Last successful update: {lastReadyAt}

    + ) : null} + {group.errorMessage ? ( +

    {group.errorMessage}

    + ) : null} +
    + +
    +
  • + ); + })} +
+ ) : null; + const datasetDetails = failureGroupDetails ?? historicalDatasetDetails; if (target.action === "recompute" && visibleDatasets.length === 0) { return ( @@ -146,6 +201,11 @@ export function ProcessingStatusWidget({ status={data.overallStatus} > {datasetDetails} + {dismissMutation.error ? ( +

+ {dismissMutation.error.message} +

+ ) : null} ); } diff --git a/packages/web/src/pages/AlertsPage.stories.tsx b/packages/web/src/pages/AlertsPage.stories.tsx index d6eb612d00..ccba169db3 100644 --- a/packages/web/src/pages/AlertsPage.stories.tsx +++ b/packages/web/src/pages/AlertsPage.stories.tsx @@ -28,22 +28,24 @@ function occurredMinutesAgo(minutes: number): string { const activeAlerts = [ { - id: "garmin-sync:activity", + id: "00000000-0000-4000-8000-00000000a001", providerId: "garmin", providerLabel: "Garmin", - datasetKey: "activity", + datasetKeys: ["activity", "providers"], + datasetLabels: ["Activities", "Provider summaries"], occurredAt: occurredMinutesAgo(4), - title: "Garmin activities weren’t updated", + title: "Garmin sync didn’t finish", message: - "Your Garmin data synced, but Dofek couldn’t update activities. Your previously synced data is still available.", + "Dofek couldn’t get the latest data from Garmin. Reconnect Garmin, then start the sync again.", action: "retry_sync", actionLabel: "Retry Garmin sync", }, { - id: "whoop-sync:recovery", + id: "00000000-0000-4000-8000-00000000a002", providerId: "whoop", providerLabel: "WHOOP", - datasetKey: "recovery", + datasetKeys: ["recovery", "sleep"], + datasetLabels: ["Recovery", "Sleep"], occurredAt: occurredMinutesAgo(18), title: "WHOOP couldn’t sync", message: @@ -52,10 +54,11 @@ const activeAlerts = [ actionLabel: "Reconnect WHOOP", }, { - id: "apple-health-import:sleep", + id: "00000000-0000-4000-8000-00000000a003", providerId: "apple_health", providerLabel: "Apple Health", - datasetKey: "sleep", + datasetKeys: ["sleep"], + datasetLabels: ["Sleep"], occurredAt: occurredMinutesAgo(42), title: "Apple Health file wasn’t imported", message: @@ -108,6 +111,14 @@ function createMockObservable( ], }, }); + } else if (path === "processing.dismiss") { + observer.next?.({ + result: { + data: { + dismissed: true, + }, + }, + }); } else { observer.error?.( TRPCClientError.from(new Error(`Unhandled Storybook tRPC operation: ${path}`)), diff --git a/packages/web/src/pages/AlertsPage.test.tsx b/packages/web/src/pages/AlertsPage.test.tsx index 59ce0f94b2..c4f68c6e54 100644 --- a/packages/web/src/pages/AlertsPage.test.tsx +++ b/packages/web/src/pages/AlertsPage.test.tsx @@ -4,14 +4,24 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AlertsPage } from "./AlertsPage.tsx"; -const { mockAlertsQuery, mockInvalidateAlerts, mockRefetchAlerts, mockRetrySync } = vi.hoisted( - () => ({ - mockAlertsQuery: vi.fn(), - mockInvalidateAlerts: vi.fn(), - mockRefetchAlerts: vi.fn(), - mockRetrySync: vi.fn(), - }), -); +const { + mockAlertsQuery, + mockDismissAlert, + mockDismissState, + mockInvalidateAlerts, + mockRefetchAlerts, + mockRetrySync, +} = vi.hoisted(() => ({ + mockAlertsQuery: vi.fn(), + mockDismissAlert: vi.fn(), + mockDismissState: { + error: null, + isPending: false, + } satisfies { error: Error | null; isPending: boolean }, + mockInvalidateAlerts: vi.fn(), + mockRefetchAlerts: vi.fn(), + mockRetrySync: vi.fn(), +})); vi.mock("../lib/trpc.ts", () => ({ trpc: { @@ -19,6 +29,18 @@ vi.mock("../lib/trpc.ts", () => ({ alerts: { useQuery: () => mockAlertsQuery(), }, + dismiss: { + useMutation: (options: { onSuccess?: () => Promise | void }) => ({ + error: mockDismissState.error, + isPending: mockDismissState.isPending, + mutate: (input: { operationId: string }) => { + mockDismissAlert(input); + if (!mockDismissState.error) { + void options.onSuccess?.(); + } + }, + }), + }, }, sync: { triggerSync: { @@ -54,32 +76,31 @@ vi.mock("@tanstack/react-router", () => ({ describe("AlertsPage", () => { beforeEach(() => { vi.clearAllMocks(); + mockDismissState.error = null; + mockDismissState.isPending = false; mockAlertsQuery.mockReturnValue({ data: { generatedAt: "2026-07-24T12:00:00.000Z", alerts: [ { - id: "operation-1:providers", - providerId: "garmin", - providerLabel: "Garmin", - datasetKey: "providers", - occurredAt: "2026-07-24T11:59:00.000Z", - title: "Garmin summary wasn’t updated", + id: "00000000-0000-4000-8000-00000000f501", + providerId: "wahoo", + providerLabel: "Wahoo", + datasetKeys: ["activity", "sleep", "recovery", "training", "body", "providers"], + datasetLabels: [ + "Activities", + "Sleep", + "Recovery", + "Training", + "Body", + "Provider summaries", + ], + occurredAt: "2026-07-08T12:00:00.000Z", + title: "Wahoo sync didn’t finish", message: - "Your Garmin data synced, but its totals couldn’t be refreshed. Your previously synced data is still available.", + "Dofek couldn’t get the latest data from Wahoo. Reconnect Wahoo, then start the sync again.", action: "retry_sync", - actionLabel: "Retry Garmin sync", - }, - { - id: "operation-2:activity", - providerId: "whoop", - providerLabel: "WHOOP (Cloud)", - datasetKey: "activity", - occurredAt: "2026-07-24T11:58:00.000Z", - title: "WHOOP (Cloud) couldn’t sync", - message: "Reconnect WHOOP (Cloud), then start the sync again.", - action: "reconnect", - actionLabel: "Reconnect WHOOP (Cloud)", + actionLabel: "Retry Wahoo sync", }, ], }, @@ -92,27 +113,92 @@ describe("AlertsPage", () => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); - it("names each affected source and presents its server-selected action", () => { + it("renders one grouped alert card with dataset labels and occurrence time", () => { + vi.setSystemTime(new Date("2026-07-24T12:00:00.000Z")); + + render(); + + expect(screen.getAllByText("Wahoo sync didn’t finish")).toHaveLength(1); + expect(screen.getAllByText("Activities")).toHaveLength(1); + expect(screen.getByText("Sleep")).toBeTruthy(); + expect(screen.getByText("Recovery")).toBeTruthy(); + expect(screen.getByText("Training")).toBeTruthy(); + expect(screen.getByText("Body")).toBeTruthy(); + expect(screen.getByText("Provider summaries")).toBeTruthy(); + expect(screen.getByText("Occurred: 16d ago")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry Wahoo sync" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss Wahoo alert" })).toBeTruthy(); + }); + + it("starts the named provider sync and refreshes active alerts", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Retry Wahoo sync" })); + + expect(mockRetrySync).toHaveBeenCalledWith({ providerId: "wahoo", sinceDays: 7 }); + expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); + expect(screen.getByText("Wahoo sync started.")).toBeTruthy(); + }); + + it("preserves reconnect actions beside grouped alert dismissal", () => { + const currentQuery = mockAlertsQuery(); + mockAlertsQuery.mockReturnValue({ + ...currentQuery, + data: { + ...currentQuery.data, + alerts: [ + { + id: "00000000-0000-4000-8000-00000000f502", + providerId: "whoop", + providerLabel: "WHOOP (Cloud)", + datasetKeys: ["activity"], + datasetLabels: ["Activities"], + occurredAt: "2026-07-24T11:58:00.000Z", + title: "WHOOP (Cloud) sync didn’t finish", + message: "Reconnect WHOOP (Cloud), then start the sync again.", + action: "reconnect", + actionLabel: "Reconnect WHOOP (Cloud)", + }, + ], + }, + }); + render(); - expect(screen.getByText("Garmin summary wasn’t updated")).toBeTruthy(); - expect(screen.getByText("WHOOP (Cloud) couldn’t sync")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Retry Garmin sync" })).toBeTruthy(); expect(screen.getByRole("link", { name: "Reconnect WHOOP (Cloud)" }).getAttribute("href")).toBe( "/providers/$id", ); + expect(screen.getByRole("button", { name: "Dismiss WHOOP (Cloud) alert" })).toBeTruthy(); }); - it("starts the named provider sync and refreshes active alerts", () => { + it("dismisses an alert by operation id and refreshes active alerts", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Retry Garmin sync" })); + fireEvent.click(screen.getByRole("button", { name: "Dismiss Wahoo alert" })); - expect(mockRetrySync).toHaveBeenCalledWith({ providerId: "garmin", sinceDays: 7 }); + expect(mockDismissAlert).toHaveBeenCalledWith({ + operationId: "00000000-0000-4000-8000-00000000f501", + }); expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); - expect(screen.getByText("Garmin sync started.")).toBeTruthy(); + }); + + it("shows the server dismissal error message", () => { + mockDismissState.error = new Error("Could not dismiss this alert."); + + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("Could not dismiss this alert."); + }); + + it("disables alert dismiss buttons while dismissal is pending", () => { + mockDismissState.isPending = true; + + render(); + + expect(screen.getByRole("button", { name: "Dismiss Wahoo alert" })).toBeDisabled(); }); it("shows a calm empty state when nothing needs attention", () => { @@ -200,7 +286,7 @@ describe("AlertsPage", () => { render(); expect(screen.getByRole("heading", { name: "Alert status may be out of date" })).toBeTruthy(); - expect(screen.getByText("Garmin summary wasn’t updated")).toBeTruthy(); + expect(screen.getByText("Wahoo sync didn’t finish")).toBeTruthy(); expect(screen.getByText(/Showing cached alerts from a previous check/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Retry alert status" })).toBeTruthy(); }); diff --git a/packages/web/src/pages/AlertsPage.tsx b/packages/web/src/pages/AlertsPage.tsx index 624fa94c0f..c8f0829bf0 100644 --- a/packages/web/src/pages/AlertsPage.tsx +++ b/packages/web/src/pages/AlertsPage.tsx @@ -25,6 +25,11 @@ export function AlertsPage() { await trpcUtils.processing.alerts.invalidate(); }, }); + const dismissMutation = trpc.processing.dismiss.useMutation({ + onSuccess: async () => { + await trpcUtils.processing.alerts.invalidate(); + }, + }); function retrySync(alert: ProcessingAlert) { if (!alert.providerId) return; @@ -69,6 +74,14 @@ export function AlertsPage() { ) : (
{failurePanel} + {dismissMutation.error ? ( +

+ {dismissMutation.error.message} +

+ ) : null} {visibleAlerts.map((alert) => (

{alert.title}

{alert.message}

-

{formatRelativeTime(alert.occurredAt)}

+
    + {alert.datasetLabels.map((label) => ( +
  • {label}
  • + ))} +
+

+ Occurred: {formatRelativeTime(alert.occurredAt)} +

+
+
+ retrySync(alert)} + /> +
- retrySync(alert)} - /> {startedProviderId === alert.providerId && alert.providerLabel ? ( From 2322bd667c4fe0a9d506686c4bc67ecaf7c54222 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:29:30 -0700 Subject: [PATCH 21/46] fix: preserve active processing state --- .../ProcessingStatusWidget.test.tsx | 50 ++++++++++++++++--- .../src/components/ProcessingStatusWidget.tsx | 21 ++++++-- packages/web/src/pages/AlertsPage.test.tsx | 21 ++++---- .../web/src/pages/ProviderDetailPage.test.tsx | 4 ++ 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/web/src/components/ProcessingStatusWidget.test.tsx b/packages/web/src/components/ProcessingStatusWidget.test.tsx index 8947bc705e..c768c3629e 100644 --- a/packages/web/src/components/ProcessingStatusWidget.test.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.test.tsx @@ -6,14 +6,17 @@ import { ProcessingStatusWidget, } from "./ProcessingStatusWidget.tsx"; -const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => ({ - mockDismissOperation: vi.fn(), - mockDismissState: { +const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => { + const mockDismissState: { error: Error | null; isPending: boolean } = { error: null, isPending: false, - } satisfies { error: Error | null; isPending: boolean }, - mockInvalidateStatus: vi.fn(), -})); + }; + return { + mockDismissOperation: vi.fn(), + mockDismissState, + mockInvalidateStatus: vi.fn(), + }; +}); vi.mock("../lib/trpc.ts", () => ({ trpc: { @@ -315,6 +318,41 @@ describe("ProcessingStatusWidget", () => { ).toBe(""); }); + it("keeps active processing visible when a separate failure was dismissed", () => { + const currentSnapshot = failedWahooSnapshot(); + const failedDataset = currentSnapshot.datasets.at(0); + const activeDataset = currentSnapshot.datasets.at(1); + const currentOperation = currentSnapshot.operations.at(0); + if (!failedDataset || !activeDataset || !currentOperation) { + throw new Error("Expected current processing fixtures"); + } + + render( + , + ); + + expect(screen.getByText("Syncing Wahoo")).toBeTruthy(); + expect(screen.getByText("Sleep")).toBeTruthy(); + }); + it("does not render an older failed group after the dataset is ready again", () => { render( dataset.status === "failed" || dataset.status === "blocked", ); @@ -116,14 +115,26 @@ export function ProcessingStatusWidget({ operations: data.operations, }); const hasFailureStatus = data.overallStatus === "failed" || data.overallStatus === "blocked"; - if (hasFailureStatus && failureGroups.length === 0) { + const inProgressDatasets = data.datasets.filter((dataset) => + ["active", "partial", "waiting", "delayed"].includes(dataset.status), + ); + if (hasFailureStatus && failureGroups.length === 0 && inProgressDatasets.length === 0) { return null; } + const displayStatus = + hasFailureStatus && failureGroups.length === 0 + ? (inProgressDatasets[0]?.status ?? data.overallStatus) + : data.overallStatus; + const heading = processingHeading(displayStatus, target); const datasetsWithHistory = data.datasets.filter( (dataset) => dataset.status !== "ready" || dataset.lastAdvancedAt !== null || dataset.lastReadyAt !== null, ); - const visibleDatasets = alwaysVisible ? datasetsWithHistory : problemDatasets; + const visibleDatasets = alwaysVisible + ? datasetsWithHistory + : failureGroups.length > 0 + ? problemDatasets + : inProgressDatasets; const historicalDatasetDetails = visibleDatasets.length > 0 ? (
    @@ -186,7 +197,7 @@ export function ProcessingStatusWidget({ ) : null; const datasetDetails = failureGroupDetails ?? historicalDatasetDetails; - if (target.action === "recompute" && visibleDatasets.length === 0) { + if (target.action === "recompute" && failureGroups.length === 0) { return ( ); @@ -198,7 +209,7 @@ export function ProcessingStatusWidget({ heading={heading} message={statusMessage} progress={progress} - status={data.overallStatus} + status={displayStatus} > {datasetDetails} {dismissMutation.error ? ( diff --git a/packages/web/src/pages/AlertsPage.test.tsx b/packages/web/src/pages/AlertsPage.test.tsx index c4f68c6e54..476e47fd0e 100644 --- a/packages/web/src/pages/AlertsPage.test.tsx +++ b/packages/web/src/pages/AlertsPage.test.tsx @@ -11,17 +11,20 @@ const { mockInvalidateAlerts, mockRefetchAlerts, mockRetrySync, -} = vi.hoisted(() => ({ - mockAlertsQuery: vi.fn(), - mockDismissAlert: vi.fn(), - mockDismissState: { +} = vi.hoisted(() => { + const mockDismissState: { error: Error | null; isPending: boolean } = { error: null, isPending: false, - } satisfies { error: Error | null; isPending: boolean }, - mockInvalidateAlerts: vi.fn(), - mockRefetchAlerts: vi.fn(), - mockRetrySync: vi.fn(), -})); + }; + return { + mockAlertsQuery: vi.fn(), + mockDismissAlert: vi.fn(), + mockDismissState, + mockInvalidateAlerts: vi.fn(), + mockRefetchAlerts: vi.fn(), + mockRetrySync: vi.fn(), + }; +}); vi.mock("../lib/trpc.ts", () => ({ trpc: { diff --git a/packages/web/src/pages/ProviderDetailPage.test.tsx b/packages/web/src/pages/ProviderDetailPage.test.tsx index a6b68335a4..744a242d2b 100644 --- a/packages/web/src/pages/ProviderDetailPage.test.tsx +++ b/packages/web/src/pages/ProviderDetailPage.test.tsx @@ -172,6 +172,7 @@ const mockSettingsGetGetData = vi.fn(); const mockSettingsGetSetData = vi.fn(); const mockSettingsGetInvalidate = vi.fn(); const mockProcessingStatusInvalidate = vi.fn(); +const mockProcessingDismissMutation = { mutate: vi.fn(), isPending: false, error: null }; const mockPollSyncJob = vi.fn(); const mockSyncStatusFetch = vi.fn(); @@ -179,6 +180,7 @@ vi.mock("../lib/trpc.ts", () => ({ trpc: { processing: { status: { useQuery: () => mockDataHealth }, + dismiss: { useMutation: () => mockProcessingDismissMutation }, }, sync: { providers: { useQuery: () => mockProviders }, @@ -647,6 +649,7 @@ describe("ProviderDetailPage import-only providers", () => { progressPercentage: null, lastAdvancedAt: "2026-06-29T12:00:00Z", lastReadyAt: null, + lastFailedAt: null, }, ], }; @@ -743,6 +746,7 @@ describe("ProviderDetailPage import-only providers", () => { progressPercentage: 100, lastAdvancedAt: "2026-06-30T12:00:00Z", lastReadyAt: "2026-06-30T12:00:00Z", + lastFailedAt: null, }, ], }; From aefb7c13d701e263a24af96c0d4f922874fafc6b Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:30:49 -0700 Subject: [PATCH 22/46] feat: clarify mobile processing failures --- .../(tabs)/processing-status-story-fixture.ts | 2 + .../app-stories/(tabs)/activities.stories.tsx | 1 + .../mobile/app-stories/alerts.stories.tsx | 28 ++- packages/mobile/app-tests/alerts.test.tsx | 141 +++++++++--- packages/mobile/app/alerts.tsx | 95 ++++++-- .../ProcessingStatusWidget.stories.tsx | 47 +++- .../ProcessingStatusWidget.test.tsx | 208 +++++++++++++++++- .../components/ProcessingStatusWidget.tsx | 101 ++++++++- 8 files changed, 557 insertions(+), 66 deletions(-) diff --git a/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts index 5c74abe5db..8a5a7debee 100644 --- a/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts +++ b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts @@ -50,6 +50,7 @@ interface ProcessingStatusStorySnapshot { progressPercentage: null; lastAdvancedAt: null; lastReadyAt: string; + lastFailedAt: null; }>; operations: []; } @@ -71,6 +72,7 @@ export function seedReadyProcessingStatus( progressPercentage: null, lastAdvancedAt: null, lastReadyAt: generatedAt, + lastFailedAt: null, })), operations: [], }; diff --git a/packages/mobile/app-stories/(tabs)/activities.stories.tsx b/packages/mobile/app-stories/(tabs)/activities.stories.tsx index f9726ff4c3..84d601c200 100644 --- a/packages/mobile/app-stories/(tabs)/activities.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/activities.stories.tsx @@ -252,6 +252,7 @@ function createStoryData() { progressPercentage: null, lastAdvancedAt: null, lastReadyAt: `${today}T12:00:00.000Z`, + lastFailedAt: null, }, ], operations: [], diff --git a/packages/mobile/app-stories/alerts.stories.tsx b/packages/mobile/app-stories/alerts.stories.tsx index 62fa78123b..6796a52b51 100644 --- a/packages/mobile/app-stories/alerts.stories.tsx +++ b/packages/mobile/app-stories/alerts.stories.tsx @@ -23,24 +23,26 @@ function occurredMinutesAgo(minutes: number): string { const activeAlerts = [ { - id: "garmin-sync:activity", - providerId: "garmin", - providerLabel: "Garmin", - datasetKey: "activity", + id: "00000000-0000-4000-8000-00000000f501", + providerId: "wahoo", + providerLabel: "Wahoo", + datasetKeys: ["activity", "sleep", "recovery"], + datasetLabels: ["Activities", "Sleep", "Recovery"], occurredAt: occurredMinutesAgo(4), - title: "Garmin activities weren’t updated", + title: "Wahoo sync didn’t finish", message: - "Your Garmin data synced, but Dofek couldn’t update activities. Your previously synced data is still available.", + "Dofek couldn’t get the latest data from Wahoo. Reconnect Wahoo, then start the sync again.", action: "retry_sync", - actionLabel: "Retry Garmin sync", + actionLabel: "Retry Wahoo sync", }, { - id: "whoop-sync:recovery", + id: "00000000-0000-4000-8000-00000000f502", providerId: "whoop", providerLabel: "WHOOP", - datasetKey: "recovery", + datasetKeys: ["recovery"], + datasetLabels: ["Recovery"], occurredAt: occurredMinutesAgo(18), - title: "WHOOP couldn’t sync", + title: "WHOOP sync didn’t finish", message: "Dofek couldn’t get the latest data from WHOOP. Reconnect WHOOP, then start the sync again.", action: "reconnect", @@ -83,14 +85,16 @@ function createMockObservable( result: { data: [ { - providerId: "garmin", + providerId: "wahoo", status: "started", jobId: "storybook-sync", - queueName: "provider-sync-garmin", + queueName: "provider-sync-wahoo", }, ], }, }); + } else if (path === "processing.dismiss") { + observer.next?.({ result: { data: { dismissed: true } } }); } else { observer.error?.( TRPCClientError.from(new Error(`Unhandled Storybook tRPC operation: ${path}`)), diff --git a/packages/mobile/app-tests/alerts.test.tsx b/packages/mobile/app-tests/alerts.test.tsx index c9fffa55bf..e737c67b36 100644 --- a/packages/mobile/app-tests/alerts.test.tsx +++ b/packages/mobile/app-tests/alerts.test.tsx @@ -1,15 +1,27 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AlertsScreen from "../app/alerts"; -const { mockAlertsQuery, mockInvalidateAlerts, mockPush, mockRefetchAlerts, mockRetrySync } = - vi.hoisted(() => ({ - mockAlertsQuery: vi.fn(), - mockInvalidateAlerts: vi.fn(), - mockPush: vi.fn(), - mockRefetchAlerts: vi.fn(), - mockRetrySync: vi.fn(), - })); +const { + mockAlertsQuery, + mockDismissAlert, + mockDismissState, + mockInvalidateAlerts, + mockPush, + mockRefetchAlerts, + mockRetrySync, +} = vi.hoisted(() => ({ + mockAlertsQuery: vi.fn(), + mockDismissAlert: vi.fn(), + mockDismissState: { + error: null, + isPending: false, + } satisfies { error: Error | null; isPending: boolean }, + mockInvalidateAlerts: vi.fn(), + mockPush: vi.fn(), + mockRefetchAlerts: vi.fn(), + mockRetrySync: vi.fn(), +})); vi.mock("../lib/useProcessingAlerts", () => ({ useProcessingAlerts: () => mockAlertsQuery(), @@ -17,6 +29,20 @@ vi.mock("../lib/useProcessingAlerts", () => ({ vi.mock("../lib/trpc", () => ({ trpc: { + processing: { + dismiss: { + useMutation: (options: { onSuccess?: () => Promise | void }) => ({ + error: mockDismissState.error, + isPending: mockDismissState.isPending, + mutate: (input: { operationId: string }) => { + mockDismissAlert(input); + if (!mockDismissState.error) { + void options.onSuccess?.(); + } + }, + }), + }, + }, sync: { triggerSync: { useMutation: (options: { onSuccess: (data: undefined, input: unknown) => void }) => ({ @@ -42,28 +68,40 @@ vi.mock("expo-router", () => ({ describe("AlertsScreen", () => { beforeEach(() => { vi.clearAllMocks(); + mockDismissState.error = null; + mockDismissState.isPending = false; mockAlertsQuery.mockReturnValue({ data: { generatedAt: "2026-07-24T12:00:00.000Z", alerts: [ { - id: "operation-1:providers", - providerId: "garmin", - providerLabel: "Garmin", - datasetKey: "providers", - occurredAt: "2026-07-24T11:59:00.000Z", - title: "Garmin summary wasn’t updated", - message: "Your previously synced Garmin data is still available.", + id: "00000000-0000-4000-8000-00000000f501", + providerId: "wahoo", + providerLabel: "Wahoo", + datasetKeys: ["activity", "sleep", "recovery", "training", "body", "providers"], + datasetLabels: [ + "Activities", + "Sleep", + "Recovery", + "Training", + "Body", + "Provider summaries", + ], + occurredAt: "2026-07-08T12:00:00.000Z", + title: "Wahoo sync didn’t finish", + message: + "Dofek couldn’t get the latest data from Wahoo. Reconnect Wahoo, then start the sync again.", action: "retry_sync", - actionLabel: "Retry Garmin sync", + actionLabel: "Retry Wahoo sync", }, { - id: "operation-2:activity", + id: "00000000-0000-4000-8000-00000000f502", providerId: "whoop", providerLabel: "WHOOP (Cloud)", - datasetKey: "activity", + datasetKeys: ["activity"], + datasetLabels: ["Activities"], occurredAt: "2026-07-24T11:58:00.000Z", - title: "WHOOP (Cloud) couldn’t sync", + title: "WHOOP (Cloud) sync didn’t finish", message: "Reconnect WHOOP (Cloud), then start the sync again.", action: "reconnect", actionLabel: "Reconnect WHOOP (Cloud)", @@ -77,15 +115,38 @@ describe("AlertsScreen", () => { }); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("renders one grouped alert card with dataset labels and occurrence time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-24T12:00:00.000Z")); + + render(); + + expect(screen.getAllByText("Wahoo sync didn’t finish")).toHaveLength(1); + expect(screen.getAllByText("Activities")).toHaveLength(2); + expect(screen.getByText("Sleep")).toBeTruthy(); + expect(screen.getByText("Recovery")).toBeTruthy(); + expect(screen.getByText("Training")).toBeTruthy(); + expect(screen.getByText("Body")).toBeTruthy(); + expect(screen.getByText("Provider summaries")).toBeTruthy(); + expect(screen.getByText("Occurred: 16d ago")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry Wahoo sync" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss Wahoo alert" })).toBeTruthy(); + }); + it("shows named active alerts and starts the selected provider sync", () => { render(); - expect(screen.getByText("Garmin summary wasn’t updated")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Retry Garmin sync" })); + expect(screen.getByText("Wahoo sync didn’t finish")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry Wahoo sync" })); - expect(mockRetrySync).toHaveBeenCalledWith({ providerId: "garmin", sinceDays: 7 }); + expect(mockRetrySync).toHaveBeenCalledWith({ providerId: "wahoo", sinceDays: 7 }); expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); - expect(screen.getByText("Garmin sync started.")).toBeTruthy(); + expect(screen.getByText("Wahoo sync started.")).toBeTruthy(); }); it("takes reconnect alerts to the affected provider", () => { @@ -96,6 +157,36 @@ describe("AlertsScreen", () => { expect(mockPush).toHaveBeenCalledWith("/providers/whoop"); }); + it("dismisses an alert by operation id and refreshes active alerts", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss Wahoo alert" })); + + expect(mockDismissAlert).toHaveBeenCalledWith({ + operationId: "00000000-0000-4000-8000-00000000f501", + }); + expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); + }); + + it("shows the server dismissal error message", () => { + mockDismissState.error = new Error("Could not dismiss this alert."); + + render(); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("Could not dismiss this alert.")).toBeTruthy(); + }); + + it("disables alert dismiss buttons while dismissal is pending", () => { + mockDismissState.isPending = true; + + render(); + + expect( + screen.getByRole("button", { name: "Dismiss Wahoo alert" }).getAttribute("aria-disabled"), + ).toBe("true"); + }); + it("shows a calm empty state when nothing needs attention", () => { mockAlertsQuery.mockReturnValue({ data: { generatedAt: "2026-07-24T12:00:00.000Z", alerts: [] }, @@ -181,7 +272,7 @@ describe("AlertsScreen", () => { render(); expect(screen.getByText("Alert status may be out of date")).toBeTruthy(); - expect(screen.getByText("Garmin summary wasn’t updated")).toBeTruthy(); + expect(screen.getByText("Wahoo sync didn’t finish")).toBeTruthy(); expect(screen.getByText(/Showing cached alerts from a previous check/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Retry alert status" })).toBeTruthy(); }); diff --git a/packages/mobile/app/alerts.tsx b/packages/mobile/app/alerts.tsx index d2b469bdb0..c67ba759fd 100644 --- a/packages/mobile/app/alerts.tsx +++ b/packages/mobile/app/alerts.tsx @@ -28,6 +28,11 @@ export default function AlertsScreen() { await trpcUtils.processing.alerts.invalidate(); }, }); + const dismissMutation = trpc.processing.dismiss.useMutation({ + onSuccess: async () => { + await trpcUtils.processing.alerts.invalidate(); + }, + }); function handleAction(alert: ProcessingAlert) { if (alert.action === "retry_sync" && alert.providerId) { @@ -83,24 +88,53 @@ export default function AlertsScreen() { ) : ( {failurePanel} + {dismissMutation.error ? ( + + {dismissMutation.error.message} + + ) : null} {visibleAlerts.map((alert) => ( {alert.title} {alert.message} - {formatRelativeTime(alert.occurredAt)} - handleAction(alert)} - style={({ pressed }) => [ - styles.action, - pressed && styles.actionPressed, - syncMutation.isPending && styles.actionDisabled, - ]} - > - {alert.actionLabel} - + + {alert.datasetLabels.map((label) => ( + + {label} + + ))} + + Occurred: {formatRelativeTime(alert.occurredAt)} + + handleAction(alert)} + style={({ pressed }) => [ + styles.action, + pressed && styles.actionPressed, + syncMutation.isPending && styles.actionDisabled, + ]} + > + {alert.actionLabel} + + dismissMutation.mutate({ operationId: alert.id })} + style={({ pressed }) => [ + styles.dismissAction, + pressed && styles.actionPressed, + dismissMutation.isPending && styles.actionDisabled, + ]} + > + Dismiss + + {startedProviderId === alert.providerId && alert.providerLabel ? ( {alert.providerLabel} sync started. @@ -169,12 +203,38 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: spacing.sm, }, + datasetLabels: { + flexDirection: "row", + flexWrap: "wrap", + gap: spacing.xs, + marginTop: spacing.sm, + }, + datasetLabel: { + color: colors.text, + fontSize: 12, + fontWeight: "600", + }, + actions: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: spacing.sm, + marginTop: spacing.md, + }, action: { alignItems: "center", alignSelf: "flex-start", backgroundColor: colors.accent, borderRadius: radius.md, - marginTop: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + dismissAction: { + alignItems: "center", + alignSelf: "flex-start", + borderColor: colors.surfaceSecondary, + borderRadius: radius.md, + borderWidth: 1, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, }, @@ -189,6 +249,11 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "700", }, + dismissActionText: { + color: colors.text, + fontSize: 13, + fontWeight: "700", + }, success: { color: colors.positive, fontSize: 12, diff --git a/packages/mobile/components/ProcessingStatusWidget.stories.tsx b/packages/mobile/components/ProcessingStatusWidget.stories.tsx index a8d690ac07..2138f1b1a4 100644 --- a/packages/mobile/components/ProcessingStatusWidget.stories.tsx +++ b/packages/mobile/components/ProcessingStatusWidget.stories.tsx @@ -1,5 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { OperationResultObservable, TRPCLink } from "@trpc/client"; +import type { AppRouter } from "dofek-server/router"; +import { type ReactNode, useMemo } from "react"; import { View } from "react-native"; +import { trpc } from "../lib/trpc"; import { type ProcessingStatusSnapshot, ProcessingStatusWidget } from "./ProcessingStatusWidget"; const activeSnapshot: ProcessingStatusSnapshot = { @@ -15,6 +20,7 @@ const activeSnapshot: ProcessingStatusSnapshot = { progressPercentage: 60, lastAdvancedAt: "2026-07-22T11:59:00.000Z", lastReadyAt: "2026-07-21T12:00:00.000Z", + lastFailedAt: null, }, ], operations: [ @@ -25,8 +31,11 @@ const activeSnapshot: ProcessingStatusSnapshot = { createdAt: "2026-07-22T11:58:00.000Z", status: "active", datasets: ["activity"], + dismissed: false, + errorMessage: null, timeline: [ { + sequence: 1, stage: "ingest", status: "succeeded", datasetKey: "activity", @@ -48,10 +57,43 @@ if (!activeOperation) throw new Error("Expected the processing story to include const activeTimelineEvent = activeOperation.timeline.at(0); if (!activeTimelineEvent) throw new Error("Expected the processing story to include an event"); +function createMockLink(): TRPCLink { + return () => + ({ op }) => { + const result: OperationResultObservable = { + subscribe(observer) { + if (op.path !== "processing.dismiss") { + throw new Error(`Unhandled processing status story tRPC operation: ${op.path}`); + } + observer.next?.({ result: { data: { dismissed: true } } }); + observer.complete?.(); + return { unsubscribe: () => {} }; + }, + pipe() { + return result; + }, + }; + return result; + }; +} + +function StoryFrame({ children }: { children: ReactNode }) { + const queryClient = useMemo(() => new QueryClient(), []); + const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink()] }), []); + + return ( + + + {children} + + + ); +} + const meta = { title: "State/ProcessingStatusWidget", component: ProcessingStatusWidget, - decorators: [(Story) => {Story()}], + decorators: [(Story) => {Story()}], args: { data: activeSnapshot }, } satisfies Meta; export default meta; @@ -108,12 +150,15 @@ export const Failed: Story = { ...activeDataset, status: "failed", progressPercentage: null, + lastFailedAt: "2026-07-22T12:05:00.000Z", }, ], operations: [ { ...activeOperation, status: "failed", + dismissed: false, + errorMessage: "Reconnect Garmin, then start the sync again.", timeline: [ { ...activeTimelineEvent, diff --git a/packages/mobile/components/ProcessingStatusWidget.test.tsx b/packages/mobile/components/ProcessingStatusWidget.test.tsx index cd9150eba0..5fa1ffde99 100644 --- a/packages/mobile/components/ProcessingStatusWidget.test.tsx +++ b/packages/mobile/components/ProcessingStatusWidget.test.tsx @@ -1,7 +1,42 @@ -import { render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ProcessingStatusSnapshot, ProcessingStatusWidget } from "./ProcessingStatusWidget"; +const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => ({ + mockDismissOperation: vi.fn(), + mockDismissState: { + error: null, + isPending: false, + } satisfies { error: Error | null; isPending: boolean }, + mockInvalidateStatus: vi.fn(), +})); + +vi.mock("../lib/trpc", () => ({ + trpc: { + processing: { + dismiss: { + useMutation: (options: { onSuccess?: () => Promise | void }) => ({ + error: mockDismissState.error, + isPending: mockDismissState.isPending, + mutate: (input: { operationId: string }) => { + mockDismissOperation(input); + if (!mockDismissState.error) { + void options.onSuccess?.(); + } + }, + }), + }, + }, + useUtils: () => ({ + processing: { + status: { + invalidate: mockInvalidateStatus, + }, + }, + }), + }, +})); + const snapshot: ProcessingStatusSnapshot = { generatedAt: "2026-07-22T12:00:00.000Z", scope: { providerId: "garmin", datasets: ["activity"] }, @@ -15,6 +50,7 @@ const snapshot: ProcessingStatusSnapshot = { progressPercentage: 60, lastAdvancedAt: "2026-07-22T11:59:00.000Z", lastReadyAt: null, + lastFailedAt: null, }, ], operations: [ @@ -25,8 +61,11 @@ const snapshot: ProcessingStatusSnapshot = { createdAt: "2026-07-22T11:58:00.000Z", status: "active", datasets: ["activity"], + dismissed: false, + errorMessage: null, timeline: [ { + sequence: 1, stage: "ingest", status: "succeeded", datasetKey: "activity", @@ -47,9 +86,56 @@ const operation = snapshot.operations.at(0); if (!operation) throw new Error("Expected the processing snapshot fixture to include an operation"); const timelineEvent = operation.timeline.at(0); if (!timelineEvent) throw new Error("Expected the processing snapshot fixture to include an event"); +const wahooDatasetLabels = [ + ["activity", "Activities"], + ["sleep", "Sleep"], + ["recovery", "Recovery"], + ["training", "Training"], + ["body", "Body"], + ["providers", "Provider summaries"], +] as const; + +function failedWahooSnapshot(overrides: Partial = {}) { + const failedDatasets = wahooDatasetLabels.map(([key, label]) => ({ + ...activityDataset, + key, + label, + status: "failed" as const, + progressPercentage: null, + lastReadyAt: "2026-07-22T16:00:00.000Z", + lastFailedAt: "2026-07-22T16:05:00.000Z", + })); + return { + ...snapshot, + generatedAt: "2026-07-22T16:10:00.000Z", + scope: { providerId: "wahoo", datasets: failedDatasets.map((dataset) => dataset.key) }, + overallStatus: "failed" as const, + datasets: failedDatasets, + operations: [ + { + ...operation, + id: "00000000-0000-4000-8000-00000000f501", + providerId: "wahoo", + status: "failed" as const, + datasets: failedDatasets.map((dataset) => dataset.key), + dismissed: false, + errorMessage: "Wahoo returned a server error. Reconnect Wahoo, then try again.", + timeline: [], + }, + ], + ...overrides, + } satisfies ProcessingStatusSnapshot; +} describe("ProcessingStatusWidget", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDismissState.error = null; + mockDismissState.isPending = false; + }); + afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -144,6 +230,7 @@ describe("ProcessingStatusWidget", () => { ...activityDataset, status, progressPercentage: null, + lastFailedAt: "2026-07-22T13:00:00.000Z", lastReadyAt: "2026-07-22T12:00:00.000Z", }, ], @@ -151,6 +238,8 @@ describe("ProcessingStatusWidget", () => { { ...operation, status, + dismissed: false, + errorMessage: "Reconnect Garmin, then start the sync again.", timeline: [ { ...timelineEvent, @@ -166,11 +255,107 @@ describe("ProcessingStatusWidget", () => { expect(screen.getByText("Garmin sync didn’t finish")).toBeTruthy(); expect(screen.getByText("Activities")).toBeTruthy(); - expect(screen.getByText(status === "failed" ? "Failed" : "Blocked")).toBeTruthy(); - expect(screen.getByText("Last ready: 2h ago")).toBeTruthy(); + expect(screen.getByText(`${status === "failed" ? "Failed" : "Blocked"}: 1h ago`)).toBeTruthy(); + expect(screen.getByText("Last successful update: 2h ago")).toBeTruthy(); expect(screen.getByText("Reconnect Garmin, then start the sync again.")).toBeTruthy(); }); + it("groups current failed datasets by operation and offers dismissal", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T16:05:00.000Z")); + + render(); + + expect(screen.getByText("Wahoo sync didn’t finish")).toBeTruthy(); + expect(screen.getAllByText("Activities")).toHaveLength(1); + expect(screen.getByText("Sleep")).toBeTruthy(); + expect(screen.getByText("Recovery")).toBeTruthy(); + expect(screen.getByText("Training")).toBeTruthy(); + expect(screen.getByText("Body")).toBeTruthy(); + expect(screen.getByText("Provider summaries")).toBeTruthy(); + expect(screen.getByText("Failed: 16d ago")).toBeTruthy(); + expect(screen.getByText("Last successful update: 16d ago")).toBeTruthy(); + expect( + screen.getByText("Wahoo returned a server error. Reconnect Wahoo, then try again."), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeTruthy(); + }); + + it("dismisses a failure group by operation and refreshes the status query", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })); + + expect(mockDismissOperation).toHaveBeenCalledWith({ + operationId: "00000000-0000-4000-8000-00000000f501", + }); + await waitFor(() => expect(mockInvalidateStatus).toHaveBeenCalledOnce()); + }); + + it("disables the dismiss button while dismissal is pending", () => { + mockDismissState.isPending = true; + + render(); + + expect( + screen + .getByRole("button", { name: "Dismiss Wahoo sync failure" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + }); + + it("hides dismissed failure groups even when the widget is always visible", () => { + const currentSnapshot = failedWahooSnapshot(); + const currentOperation = currentSnapshot.operations.at(0); + if (!currentOperation) throw new Error("Expected a current operation"); + const dismissedSnapshot = failedWahooSnapshot({ + operations: [{ ...currentOperation, dismissed: true }], + }); + + expect( + render().container.innerHTML, + ).toBe(""); + }); + + it("does not render an older failed group after the dataset is ready again", () => { + render( + , + ); + + expect(screen.queryByText("This older failure is resolved.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeNull(); + }); + + it("shows one operation-level mutation error after a dismiss attempt fails", () => { + mockDismissState.error = new Error("Could not dismiss this sync failure."); + + render(); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getAllByText("Could not dismiss this sync failure.")).toHaveLength(1); + }); + it("shows every dataset and its freshness when explicitly kept visible", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-22T14:00:00.000Z")); @@ -185,6 +370,7 @@ describe("ProcessingStatusWidget", () => { status: "ready", progressPercentage: 100, lastReadyAt: "2026-07-22T12:00:00.000Z", + lastFailedAt: null, }, ], }} @@ -208,12 +394,15 @@ describe("ProcessingStatusWidget", () => { ...activityDataset, status: "ready", progressPercentage: 100, + lastFailedAt: null, }, ], operations: [ { ...operation, status: "ready", + dismissed: false, + errorMessage: null, timeline: [ { ...timelineEvent, @@ -244,6 +433,14 @@ describe("ProcessingStatusWidget", () => { ...activityDataset, status: "failed", progressPercentage: null, + lastFailedAt: "2026-07-22T13:00:00.000Z", + }, + ], + operations: [ + { + ...operation, + status: "failed", + errorMessage: null, }, ], }} @@ -251,7 +448,7 @@ describe("ProcessingStatusWidget", () => { ); expect(screen.getByText("Activities")).toBeTruthy(); - expect(screen.getByText("Failed")).toBeTruthy(); + expect(screen.getByText(/Failed:/)).toBeTruthy(); }); it("does not claim freshness for synthetic ready datasets without processing history", () => { @@ -268,6 +465,7 @@ describe("ProcessingStatusWidget", () => { progressPercentage: null, lastAdvancedAt: null, lastReadyAt: null, + lastFailedAt: null, }, ], }} diff --git a/packages/mobile/components/ProcessingStatusWidget.tsx b/packages/mobile/components/ProcessingStatusWidget.tsx index 7b41249b45..dfe744b7a4 100644 --- a/packages/mobile/components/ProcessingStatusWidget.tsx +++ b/packages/mobile/components/ProcessingStatusWidget.tsx @@ -3,13 +3,14 @@ import { type ProcessingDisplayStage, type ProcessingDisplayStatus, processingAggregateProgress, - processingDatasetErrorMessage, processingDatasetStatusLabel, + processingFailureGroups, processingHeading, processingStatusMessage, processingTarget, } from "@dofek/providers/processing-status"; -import { StyleSheet, Text, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { trpc } from "../lib/trpc"; import { colors, spacing } from "../theme"; import { RecomputeStatusIndicator } from "./RecomputeStatusIndicator"; import { SourceProcessingStatusCard } from "./SourceProcessingStatusCard"; @@ -26,6 +27,7 @@ export interface ProcessingStatusSnapshot { progressPercentage: number | null; lastAdvancedAt: string | null; lastReadyAt: string | null; + lastFailedAt: string | null; }>; operations: Array<{ id: string; @@ -34,7 +36,10 @@ export interface ProcessingStatusSnapshot { createdAt: string; status: ProcessingDisplayStatus; datasets: string[]; + dismissed: boolean; + errorMessage: string | null; timeline: Array<{ + sequence: number; stage: ProcessingDisplayStage; status: string; datasetKey: string | null; @@ -63,6 +68,13 @@ export function ProcessingStatusWidget({ contextLabel, alwaysVisible = false, }: ProcessingStatusWidgetProps) { + const trpcUtils = trpc.useUtils(); + const dismissMutation = trpc.processing.dismiss.useMutation({ + onSuccess: async () => { + await trpcUtils.processing.status.invalidate(); + }, + }); + if (loading && !data) return null; if (error && !data) { return ( @@ -92,20 +104,24 @@ export function ProcessingStatusWidget({ const problemDatasets = data.datasets.filter( (dataset) => dataset.status === "failed" || dataset.status === "blocked", ); + const failureGroups = processingFailureGroups({ + datasets: data.datasets, + operations: data.operations, + }); + const hasFailureStatus = data.overallStatus === "failed" || data.overallStatus === "blocked"; + if (hasFailureStatus && failureGroups.length === 0) { + return null; + } const datasetsWithHistory = data.datasets.filter( (dataset) => dataset.status !== "ready" || dataset.lastAdvancedAt !== null || dataset.lastReadyAt !== null, ); const visibleDatasets = alwaysVisible ? datasetsWithHistory : problemDatasets; - const datasetDetails = + const historicalDatasetDetails = visibleDatasets.length > 0 ? ( {visibleDatasets.map((dataset) => { const lastReady = dataset.lastReadyAt ? formatRelativeTime(dataset.lastReadyAt) : null; - const datasetError = - dataset.status === "failed" || dataset.status === "blocked" - ? processingDatasetErrorMessage(data.operations, dataset.key) - : null; return ( @@ -117,12 +133,58 @@ export function ProcessingStatusWidget({ {lastReady ? `Last ready: ${lastReady}` : "No completed update recorded"} - {datasetError ? {datasetError} : null} ); })} ) : null; + const failureGroupDetails = + failureGroups.length > 0 ? ( + + {failureGroups.map((group) => { + const failedAt = group.failedAt ? formatRelativeTime(group.failedAt) : null; + const lastReadyAt = group.lastReadyAt ? formatRelativeTime(group.lastReadyAt) : null; + const labelPrefix = group.providerLabel ? `${group.providerLabel} sync` : "data update"; + return ( + + + + + {group.datasetLabels.map((label) => ( + + {label} + + ))} + + + {processingDatasetStatusLabel(group.status)}: {failedAt ?? "not recorded"} + + {lastReadyAt ? ( + + Last successful update: {lastReadyAt} + + ) : null} + {group.errorMessage ? ( + {group.errorMessage} + ) : null} + + dismissMutation.mutate({ operationId: group.operationId })} + style={[styles.dismissButton, dismissMutation.isPending && styles.actionDisabled]} + > + Dismiss + + + + ); + })} + + ) : null; + const datasetDetails = failureGroupDetails ?? historicalDatasetDetails; if (target.action === "recompute" && visibleDatasets.length === 0) { return ( @@ -139,6 +201,11 @@ export function ProcessingStatusWidget({ status={data.overallStatus} > {datasetDetails} + {dismissMutation.error ? ( + + {dismissMutation.error.message} + + ) : null} ); } @@ -162,7 +229,25 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, datasetLabel: { color: colors.text, fontSize: 12, fontWeight: "700" }, + datasetLabels: { flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }, datasetStatus: { color: colors.textSecondary, fontSize: 12 }, datasetFreshness: { color: colors.textTertiary, fontSize: 12 }, datasetError: { color: colors.danger, fontSize: 12, lineHeight: 17, marginTop: 2 }, + failureGroupHeader: { + alignItems: "flex-start", + flexDirection: "row", + gap: spacing.sm, + justifyContent: "space-between", + }, + failureGroupCopy: { flex: 1, gap: 2 }, + dismissButton: { + alignItems: "center", + borderColor: colors.surfaceSecondary, + borderRadius: 6, + borderWidth: 1, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + dismissButtonText: { color: colors.text, fontSize: 12, fontWeight: "700" }, + actionDisabled: { opacity: 0.5 }, }); From 59f1b35549d5dec6ac9aafe8a24ff46acdfc8b30 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:32:29 -0700 Subject: [PATCH 23/46] fix: color active recompute status --- packages/web/src/components/ProcessingStatusWidget.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/web/src/components/ProcessingStatusWidget.tsx b/packages/web/src/components/ProcessingStatusWidget.tsx index c44e94d979..05b325eeca 100644 --- a/packages/web/src/components/ProcessingStatusWidget.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.tsx @@ -198,9 +198,7 @@ export function ProcessingStatusWidget({ const datasetDetails = failureGroupDetails ?? historicalDatasetDetails; if (target.action === "recompute" && failureGroups.length === 0) { - return ( - - ); + return ; } return ( From 4902724405d12c9194a2159fc9cf9c71d6d6793e Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:43:11 -0700 Subject: [PATCH 24/46] test: cover processing alert persistence --- .../processing-repository.integration.test.ts | 207 ++++++++++++++++++ .../processing-repository.test.ts | 8 +- .../src/repositories/processing-repository.ts | 10 +- .../server/src/routers/processing.test.ts | 14 +- 4 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 packages/server/src/repositories/processing-repository.integration.test.ts diff --git a/packages/server/src/repositories/processing-repository.integration.test.ts b/packages/server/src/repositories/processing-repository.integration.test.ts new file mode 100644 index 0000000000..1eb30271fd --- /dev/null +++ b/packages/server/src/repositories/processing-repository.integration.test.ts @@ -0,0 +1,207 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts"; +import type { ProcessingDatasetKey } from "../../../../src/processing/dataset-contracts.ts"; +import { appendProcessingStageEvent } from "../../../../src/processing/processing-event-store.ts"; +import { ProcessingRepository } from "./processing-repository.ts"; + +const userId = "20000000-0000-4000-8000-000000000001"; +const failedOperationId = "20000000-0000-4000-8000-000000000010"; +const readyOperationId = "20000000-0000-4000-8000-000000000011"; +const datasetKeys = ["activity", "recovery", "sleep"] satisfies ProcessingDatasetKey[]; + +describe("ProcessingRepository integration", () => { + let testContext: TestContext; + + beforeAll(async () => { + testContext = await setupTestDatabase(); + await testContext.db.execute(sql` + INSERT INTO fitness.user_profile (id, name) + VALUES (${userId}::uuid, 'Processing Repository Test User') + `); + }, 120_000); + + afterAll(async () => { + await testContext?.cleanup(); + }); + + async function insertOperation(input: { + operationId: string; + externalCorrelationKey: string; + createdAt: string; + }): Promise { + await testContext.db.execute(sql` + INSERT INTO fitness.processing_operation ( + id, + user_id, + provider_id, + kind, + external_correlation_key, + dataset_keys, + created_at + ) + VALUES ( + ${input.operationId}::uuid, + ${userId}::uuid, + 'wahoo', + 'provider_sync', + ${input.externalCorrelationKey}, + ARRAY[${sql.join( + datasetKeys.map((datasetKey) => sql`${datasetKey}`), + sql`, `, + )}]::text[], + ${input.createdAt}::timestamptz + ) + `); + } + + async function appendSucceededStage(input: { + operationId: string; + stage: "ingest" | "analytics" | "cache_refresh"; + datasetKey: ProcessingDatasetKey | null; + occurredAt: string; + idempotencyKey: string; + }): Promise { + await appendProcessingStageEvent(testContext.db, { + operationId: input.operationId, + stage: input.stage, + status: "succeeded", + datasetKey: input.datasetKey, + occurredAt: new Date(input.occurredAt), + idempotencyKey: input.idempotencyKey, + }); + } + + async function seedFailedOperation(): Promise { + await insertOperation({ + operationId: failedOperationId, + externalCorrelationKey: "processing-repository-integration-failed", + createdAt: "2026-07-22T16:00:00.000Z", + }); + await appendSucceededStage({ + operationId: failedOperationId, + stage: "ingest", + datasetKey: null, + occurredAt: "2026-07-22T16:01:00.000Z", + idempotencyKey: "failed-ingest-succeeded", + }); + + for (const [datasetIndex, datasetKey] of datasetKeys.entries()) { + await appendProcessingStageEvent(testContext.db, { + operationId: failedOperationId, + stage: "analytics", + status: "failed", + datasetKey, + occurredAt: new Date(`2026-07-22T16:0${datasetIndex + 2}:00.000Z`), + errorCode: "analytics_failed", + errorMessage: `${datasetKey} analytics failed`, + idempotencyKey: `failed-analytics-${datasetKey}`, + }); + } + } + + async function seedReadyOperation(): Promise { + await insertOperation({ + operationId: readyOperationId, + externalCorrelationKey: "processing-repository-integration-ready", + createdAt: "2026-07-22T17:00:00.000Z", + }); + await appendSucceededStage({ + operationId: readyOperationId, + stage: "ingest", + datasetKey: null, + occurredAt: "2026-07-22T17:01:00.000Z", + idempotencyKey: "ready-ingest-succeeded", + }); + + for (const datasetKey of datasetKeys) { + await appendSucceededStage({ + operationId: readyOperationId, + stage: "analytics", + datasetKey, + occurredAt: "2026-07-22T17:02:00.000Z", + idempotencyKey: `ready-analytics-${datasetKey}`, + }); + await appendSucceededStage({ + operationId: readyOperationId, + stage: "cache_refresh", + datasetKey, + occurredAt: "2026-07-22T17:03:00.000Z", + idempotencyKey: `ready-cache-refresh-${datasetKey}`, + }); + } + } + + it("derives grouped alerts, dismissals, and later ready status from migrated tables", async () => { + await seedFailedOperation(); + const repository = new ProcessingRepository(testContext.db, userId); + + const failedStatus = await repository.status({ providerId: "wahoo" }); + + expect(failedStatus.datasets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "activity", + status: "failed", + lastFailedAt: "2026-07-22T16:02:00.000Z", + }), + expect.objectContaining({ + key: "recovery", + status: "failed", + lastFailedAt: "2026-07-22T16:03:00.000Z", + }), + expect.objectContaining({ + key: "sleep", + status: "failed", + lastFailedAt: "2026-07-22T16:04:00.000Z", + }), + ]), + ); + + await expect(repository.alerts()).resolves.toEqual({ + generatedAt: expect.any(String), + alerts: [ + expect.objectContaining({ + id: failedOperationId, + providerId: "wahoo", + datasetKeys, + datasetLabels: ["Activities", "Recovery", "Sleep"], + occurredAt: "2026-07-22T16:04:00.000Z", + action: "retry_sync", + }), + ], + }); + + await expect(repository.dismiss(failedOperationId)).resolves.toEqual({ dismissed: true }); + + const dismissedStatus = await repository.status({ providerId: "wahoo" }); + expect(dismissedStatus.operations).toEqual( + expect.arrayContaining([expect.objectContaining({ id: failedOperationId, dismissed: true })]), + ); + await expect(repository.alerts()).resolves.toEqual(expect.objectContaining({ alerts: [] })); + + await seedReadyOperation(); + const readyStatus = await repository.status({ providerId: "wahoo" }); + + expect(readyStatus.datasets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "activity", + status: "ready", + lastFailedAt: "2026-07-22T16:02:00.000Z", + }), + expect.objectContaining({ + key: "recovery", + status: "ready", + lastFailedAt: "2026-07-22T16:03:00.000Z", + }), + expect.objectContaining({ + key: "sleep", + status: "ready", + lastFailedAt: "2026-07-22T16:04:00.000Z", + }), + ]), + ); + await expect(repository.alerts()).resolves.toEqual(expect.objectContaining({ alerts: [] })); + }); +}); diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index 7079f7dfe0..3aac920610 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -1,8 +1,8 @@ -import { TRPCError } from "@trpc/server"; +import type { TRPCError } from "@trpc/server"; import type { Database } from "dofek/db"; -import { PgDialect } from "drizzle-orm/pg-core"; import type { ProcessingOperationWithEvents } from "dofek/processing/processing-event-store"; import type { DerivedProcessingStatus } from "dofek/processing/processing-state"; +import { PgDialect } from "drizzle-orm/pg-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { @@ -1389,9 +1389,7 @@ describe("ProcessingRepository", () => { const compiledQuery = postgresDialect.sqlToQuery(mockExecuteWithSchema.mock.calls[0]?.[2]); expect(compiledQuery.sql).toContain("operation.user_id ="); - expect(compiledQuery.params).toEqual( - expect.arrayContaining([userId, operationId, userId]), - ); + expect(compiledQuery.params).toEqual(expect.arrayContaining([userId, operationId, userId])); }); it.each([ diff --git a/packages/server/src/repositories/processing-repository.ts b/packages/server/src/repositories/processing-repository.ts index 24d2bfde03..5a99028854 100644 --- a/packages/server/src/repositories/processing-repository.ts +++ b/packages/server/src/repositories/processing-repository.ts @@ -1,8 +1,7 @@ -import { TRPCError } from "@trpc/server"; import type { ProcessingAlert } from "@dofek/providers/processing-alerts"; import { providerLabel } from "@dofek/providers/providers"; +import { TRPCError } from "@trpc/server"; import type { Database } from "dofek/db"; -import { sql } from "drizzle-orm"; import { DATASET_CONTRACTS, PROCESSING_DATASET_KEYS, @@ -20,6 +19,7 @@ import { type ProcessingEventStatus, type ProcessingStage, } from "dofek/processing/processing-state"; +import { sql } from "drizzle-orm"; import { z } from "zod"; import { executeWithSchema } from "../lib/typed-sql.ts"; @@ -95,8 +95,7 @@ function compareByOccurredAtDescending ({ - mockAlerts: vi.fn(), - mockDataQuality: vi.fn(), - mockDismiss: vi.fn(), - mockEnsureProvidersRegistered: vi.fn(), - mockHistory: vi.fn(), - mockStatus: vi.fn(), - })); + mockAlerts: vi.fn(), + mockDataQuality: vi.fn(), + mockDismiss: vi.fn(), + mockEnsureProvidersRegistered: vi.fn(), + mockHistory: vi.fn(), + mockStatus: vi.fn(), +})); vi.mock("dofek/lib/cache", () => ({ queryCache: { From 243e0427be6d4c6fa5678233d684a175cfb02376 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 8 Aug 2026 18:58:17 -0700 Subject: [PATCH 25/46] fix: align processing dismissal behavior --- .../ProcessingStatusWidget.test.tsx | 63 +++++++-- .../components/ProcessingStatusWidget.tsx | 37 ++++-- .../src/processing-status.test.ts | 120 ------------------ .../providers-meta/src/processing-status.ts | 25 ---- .../processing-repository.integration.test.ts | 33 +++-- .../server/src/routers/processing.test.ts | 9 ++ packages/server/src/routers/processing.ts | 1 + .../ProcessingStatusWidget.test.tsx | 31 +++-- .../src/components/ProcessingStatusWidget.tsx | 12 +- 9 files changed, 137 insertions(+), 194 deletions(-) diff --git a/packages/mobile/components/ProcessingStatusWidget.test.tsx b/packages/mobile/components/ProcessingStatusWidget.test.tsx index 5fa1ffde99..24355d6049 100644 --- a/packages/mobile/components/ProcessingStatusWidget.test.tsx +++ b/packages/mobile/components/ProcessingStatusWidget.test.tsx @@ -2,14 +2,19 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ProcessingStatusSnapshot, ProcessingStatusWidget } from "./ProcessingStatusWidget"; -const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => ({ - mockDismissOperation: vi.fn(), - mockDismissState: { - error: null, - isPending: false, - } satisfies { error: Error | null; isPending: boolean }, - mockInvalidateStatus: vi.fn(), -})); +const { mockDismissOperation, mockDismissState, mockInvalidateAlerts, mockInvalidateStatus } = + vi.hoisted(() => { + const mockDismissState: { error: Error | null; isPending: boolean } = { + error: null, + isPending: false, + }; + return { + mockDismissOperation: vi.fn(), + mockDismissState, + mockInvalidateAlerts: vi.fn(), + mockInvalidateStatus: vi.fn(), + }; + }); vi.mock("../lib/trpc", () => ({ trpc: { @@ -32,6 +37,9 @@ vi.mock("../lib/trpc", () => ({ status: { invalidate: mockInvalidateStatus, }, + alerts: { + invalidate: mockInvalidateAlerts, + }, }, }), }, @@ -278,6 +286,9 @@ describe("ProcessingStatusWidget", () => { expect( screen.getByText("Wahoo returned a server error. Reconnect Wahoo, then try again."), ).toBeTruthy(); + expect( + screen.queryByText("Try the update again. If it still fails, reconnect the data source."), + ).toBeNull(); expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeTruthy(); }); @@ -290,6 +301,7 @@ describe("ProcessingStatusWidget", () => { operationId: "00000000-0000-4000-8000-00000000f501", }); await waitFor(() => expect(mockInvalidateStatus).toHaveBeenCalledOnce()); + expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); }); it("disables the dismiss button while dismissal is pending", () => { @@ -317,6 +329,41 @@ describe("ProcessingStatusWidget", () => { ).toBe(""); }); + it("keeps active processing visible when a separate failure was dismissed", () => { + const currentSnapshot = failedWahooSnapshot(); + const failedDataset = currentSnapshot.datasets.at(0); + const activeDataset = currentSnapshot.datasets.at(1); + const currentOperation = currentSnapshot.operations.at(0); + if (!failedDataset || !activeDataset || !currentOperation) { + throw new Error("Expected current processing fixtures"); + } + + render( + , + ); + + expect(screen.getByText("Syncing Wahoo")).toBeTruthy(); + expect(screen.getByText("Sleep")).toBeTruthy(); + }); + it("does not render an older failed group after the dataset is ready again", () => { render( { await trpcUtils.processing.status.invalidate(); + await trpcUtils.processing.alerts.invalidate(); }, }); @@ -96,11 +97,6 @@ export function ProcessingStatusWidget({ datasets: data.datasets, operationKind: data.operations[0]?.kind, }); - const statusMessage = processingStatusMessage({ - status: data.overallStatus, - errorMessage: null, - }); - const heading = processingHeading(data.overallStatus, target); const problemDatasets = data.datasets.filter( (dataset) => dataset.status === "failed" || dataset.status === "blocked", ); @@ -108,15 +104,34 @@ export function ProcessingStatusWidget({ datasets: data.datasets, operations: data.operations, }); + const statusMessage = + failureGroups.length > 0 + ? null + : processingStatusMessage({ + status: data.overallStatus, + errorMessage: null, + }); const hasFailureStatus = data.overallStatus === "failed" || data.overallStatus === "blocked"; - if (hasFailureStatus && failureGroups.length === 0) { + const inProgressDatasets = data.datasets.filter((dataset) => + ["active", "partial", "waiting", "delayed"].includes(dataset.status), + ); + if (hasFailureStatus && failureGroups.length === 0 && inProgressDatasets.length === 0) { return null; } + const displayStatus = + hasFailureStatus && failureGroups.length === 0 + ? (inProgressDatasets[0]?.status ?? data.overallStatus) + : data.overallStatus; + const heading = processingHeading(displayStatus, target); const datasetsWithHistory = data.datasets.filter( (dataset) => dataset.status !== "ready" || dataset.lastAdvancedAt !== null || dataset.lastReadyAt !== null, ); - const visibleDatasets = alwaysVisible ? datasetsWithHistory : problemDatasets; + const visibleDatasets = alwaysVisible + ? datasetsWithHistory + : failureGroups.length > 0 + ? problemDatasets + : inProgressDatasets; const historicalDatasetDetails = visibleDatasets.length > 0 ? ( @@ -186,10 +201,8 @@ export function ProcessingStatusWidget({ ) : null; const datasetDetails = failureGroupDetails ?? historicalDatasetDetails; - if (target.action === "recompute" && visibleDatasets.length === 0) { - return ( - - ); + if (target.action === "recompute" && failureGroups.length === 0) { + return ; } return ( @@ -198,7 +211,7 @@ export function ProcessingStatusWidget({ heading={heading} message={statusMessage} progress={progress} - status={data.overallStatus} + status={displayStatus} > {datasetDetails} {dismissMutation.error ? ( diff --git a/packages/providers-meta/src/processing-status.test.ts b/packages/providers-meta/src/processing-status.test.ts index b781693164..9788685e04 100644 --- a/packages/providers-meta/src/processing-status.test.ts +++ b/packages/providers-meta/src/processing-status.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { processingAggregateProgress, - processingDatasetErrorMessage, processingDatasetStatusLabel, processingFailureGroups, processingHeading, @@ -357,125 +356,6 @@ describe("processing status presentation", () => { ]); }); - it("uses the latest matching failed event as the dataset error", () => { - expect( - processingDatasetErrorMessage( - [ - { - datasets: ["activity"], - timeline: [ - { - datasetKey: "sleep", - status: "failed", - occurredAt: "2026-07-22T10:00:00.000Z", - message: "Sleep failed", - errorMessage: "Old sleep error", - }, - { - datasetKey: null, - status: "failed", - occurredAt: "2026-07-22T11:00:00.000Z", - message: null, - errorMessage: "Reconnect the provider.", - }, - { - datasetKey: "activity", - status: "succeeded", - occurredAt: "2026-07-22T12:00:00.000Z", - message: "Ignore success", - errorMessage: null, - }, - ], - }, - ], - "activity", - ), - ).toBe("Reconnect the provider."); - }); - - it("falls back to a failed event message and ignores other datasets", () => { - expect( - processingDatasetErrorMessage( - [ - { - datasets: ["activity", "sleep"], - timeline: [ - { - datasetKey: "sleep", - status: "failed", - occurredAt: "2026-07-22T12:00:00.000Z", - message: "Sleep failed", - errorMessage: null, - }, - { - datasetKey: "activity", - status: "failed", - occurredAt: "2026-07-22T11:00:00.000Z", - message: "Try the activity sync again.", - errorMessage: null, - }, - ], - }, - ], - "activity", - ), - ).toBe("Try the activity sync again."); - }); - - it("ignores failures from older and unrelated operations", () => { - expect( - processingDatasetErrorMessage( - [ - { - datasets: ["activity"], - timeline: [ - { - datasetKey: "activity", - status: "succeeded", - occurredAt: "2026-07-22T12:00:00.000Z", - message: "Activity ready", - errorMessage: null, - }, - ], - }, - { - datasets: ["activity"], - timeline: [ - { - datasetKey: "activity", - status: "failed", - occurredAt: "2026-07-22T11:00:00.000Z", - message: null, - errorMessage: "Old activity failure", - }, - ], - }, - ], - "activity", - ), - ).toBeNull(); - - expect( - processingDatasetErrorMessage( - [ - { - datasets: ["sleep"], - timeline: [ - { - datasetKey: null, - status: "failed", - occurredAt: "2026-07-22T12:00:00.000Z", - message: null, - errorMessage: "Sleep operation failed", - }, - ], - }, - ], - "activity", - ), - ).toBeNull(); - }); - it.each([ ["ready", "Ready"], ["waiting", "Waiting"], diff --git a/packages/providers-meta/src/processing-status.ts b/packages/providers-meta/src/processing-status.ts index 3093483e70..8c34fa03ae 100644 --- a/packages/providers-meta/src/processing-status.ts +++ b/packages/providers-meta/src/processing-status.ts @@ -239,31 +239,6 @@ export function processingFailureGroups(input: { }); } -interface ProcessingErrorEvent { - datasetKey: string | null; - status: string; - occurredAt: string; - message: string | null; - errorMessage: string | null; -} - -export function processingDatasetErrorMessage( - operations: readonly { - datasets: readonly string[]; - timeline: readonly ProcessingErrorEvent[]; - }[], - datasetKey: string, -): string | null { - const currentOperation = operations.find((operation) => operation.datasets.includes(datasetKey)); - const failedEvent = currentOperation?.timeline - .filter( - (event) => - event.status === "failed" && (event.datasetKey === null || event.datasetKey === datasetKey), - ) - .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0]; - return failedEvent?.errorMessage ?? failedEvent?.message ?? null; -} - export function processingDatasetStatusLabel(status: ProcessingDisplayStatus): string { switch (status) { case "ready": diff --git a/packages/server/src/repositories/processing-repository.integration.test.ts b/packages/server/src/repositories/processing-repository.integration.test.ts index 1eb30271fd..8f59bd4ac1 100644 --- a/packages/server/src/repositories/processing-repository.integration.test.ts +++ b/packages/server/src/repositories/processing-repository.integration.test.ts @@ -12,8 +12,13 @@ const datasetKeys = ["activity", "recovery", "sleep"] satisfies ProcessingDatase describe("ProcessingRepository integration", () => { let testContext: TestContext; + let baseTime: Date; + + const isoAt = (minutesFromBase: number) => + new Date(baseTime.getTime() + minutesFromBase * 60_000).toISOString(); beforeAll(async () => { + baseTime = new Date(Date.now() - 10 * 60_000); testContext = await setupTestDatabase(); await testContext.db.execute(sql` INSERT INTO fitness.user_profile (id, name) @@ -76,13 +81,13 @@ describe("ProcessingRepository integration", () => { await insertOperation({ operationId: failedOperationId, externalCorrelationKey: "processing-repository-integration-failed", - createdAt: "2026-07-22T16:00:00.000Z", + createdAt: isoAt(-9), }); await appendSucceededStage({ operationId: failedOperationId, stage: "ingest", datasetKey: null, - occurredAt: "2026-07-22T16:01:00.000Z", + occurredAt: isoAt(-8), idempotencyKey: "failed-ingest-succeeded", }); @@ -92,7 +97,7 @@ describe("ProcessingRepository integration", () => { stage: "analytics", status: "failed", datasetKey, - occurredAt: new Date(`2026-07-22T16:0${datasetIndex + 2}:00.000Z`), + occurredAt: new Date(isoAt(-7 + datasetIndex)), errorCode: "analytics_failed", errorMessage: `${datasetKey} analytics failed`, idempotencyKey: `failed-analytics-${datasetKey}`, @@ -104,13 +109,13 @@ describe("ProcessingRepository integration", () => { await insertOperation({ operationId: readyOperationId, externalCorrelationKey: "processing-repository-integration-ready", - createdAt: "2026-07-22T17:00:00.000Z", + createdAt: isoAt(-4), }); await appendSucceededStage({ operationId: readyOperationId, stage: "ingest", datasetKey: null, - occurredAt: "2026-07-22T17:01:00.000Z", + occurredAt: isoAt(-3), idempotencyKey: "ready-ingest-succeeded", }); @@ -119,14 +124,14 @@ describe("ProcessingRepository integration", () => { operationId: readyOperationId, stage: "analytics", datasetKey, - occurredAt: "2026-07-22T17:02:00.000Z", + occurredAt: isoAt(-2), idempotencyKey: `ready-analytics-${datasetKey}`, }); await appendSucceededStage({ operationId: readyOperationId, stage: "cache_refresh", datasetKey, - occurredAt: "2026-07-22T17:03:00.000Z", + occurredAt: isoAt(-1), idempotencyKey: `ready-cache-refresh-${datasetKey}`, }); } @@ -143,17 +148,17 @@ describe("ProcessingRepository integration", () => { expect.objectContaining({ key: "activity", status: "failed", - lastFailedAt: "2026-07-22T16:02:00.000Z", + lastFailedAt: isoAt(-7), }), expect.objectContaining({ key: "recovery", status: "failed", - lastFailedAt: "2026-07-22T16:03:00.000Z", + lastFailedAt: isoAt(-6), }), expect.objectContaining({ key: "sleep", status: "failed", - lastFailedAt: "2026-07-22T16:04:00.000Z", + lastFailedAt: isoAt(-5), }), ]), ); @@ -166,7 +171,7 @@ describe("ProcessingRepository integration", () => { providerId: "wahoo", datasetKeys, datasetLabels: ["Activities", "Recovery", "Sleep"], - occurredAt: "2026-07-22T16:04:00.000Z", + occurredAt: isoAt(-5), action: "retry_sync", }), ], @@ -188,17 +193,17 @@ describe("ProcessingRepository integration", () => { expect.objectContaining({ key: "activity", status: "ready", - lastFailedAt: "2026-07-22T16:02:00.000Z", + lastFailedAt: isoAt(-7), }), expect.objectContaining({ key: "recovery", status: "ready", - lastFailedAt: "2026-07-22T16:03:00.000Z", + lastFailedAt: isoAt(-6), }), expect.objectContaining({ key: "sleep", status: "ready", - lastFailedAt: "2026-07-22T16:04:00.000Z", + lastFailedAt: isoAt(-5), }), ]), ); diff --git a/packages/server/src/routers/processing.test.ts b/packages/server/src/routers/processing.test.ts index af82a55b6d..16bc7b52c7 100644 --- a/packages/server/src/routers/processing.test.ts +++ b/packages/server/src/routers/processing.test.ts @@ -380,4 +380,13 @@ describe("processingRouter", () => { message: "Processing operation not found.", }); }); + + it("validates the dismiss mutation response", async () => { + mockDismiss.mockResolvedValue({ dismissed: false }); + const caller = createCaller({ db: {}, userId, timezone: "UTC" }); + + await expect( + caller.dismiss({ operationId: "10000000-0000-4000-8000-000000000002" }), + ).rejects.toThrow(); + }); }); diff --git a/packages/server/src/routers/processing.ts b/packages/server/src/routers/processing.ts index a50000942b..dd0a1a6058 100644 --- a/packages/server/src/routers/processing.ts +++ b/packages/server/src/routers/processing.ts @@ -140,6 +140,7 @@ export const processingRouter = router({ .query(({ ctx }) => new ProcessingRepository(ctx.db, ctx.userId).alerts()), dismiss: protectedProcedure .input(z.object({ operationId: z.uuid() })) + .output(z.object({ dismissed: z.literal(true) })) .mutation(async ({ ctx, input }) => { const result = await new ProcessingRepository(ctx.db, ctx.userId).dismiss(input.operationId); await queryCache.invalidateByPrefix(`${ctx.userId}:processing.`); diff --git a/packages/web/src/components/ProcessingStatusWidget.test.tsx b/packages/web/src/components/ProcessingStatusWidget.test.tsx index c768c3629e..17fdb08f42 100644 --- a/packages/web/src/components/ProcessingStatusWidget.test.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.test.tsx @@ -6,17 +6,19 @@ import { ProcessingStatusWidget, } from "./ProcessingStatusWidget.tsx"; -const { mockDismissOperation, mockDismissState, mockInvalidateStatus } = vi.hoisted(() => { - const mockDismissState: { error: Error | null; isPending: boolean } = { - error: null, - isPending: false, - }; - return { - mockDismissOperation: vi.fn(), - mockDismissState, - mockInvalidateStatus: vi.fn(), - }; -}); +const { mockDismissOperation, mockDismissState, mockInvalidateAlerts, mockInvalidateStatus } = + vi.hoisted(() => { + const mockDismissState: { error: Error | null; isPending: boolean } = { + error: null, + isPending: false, + }; + return { + mockDismissOperation: vi.fn(), + mockDismissState, + mockInvalidateAlerts: vi.fn(), + mockInvalidateStatus: vi.fn(), + }; + }); vi.mock("../lib/trpc.ts", () => ({ trpc: { @@ -39,6 +41,9 @@ vi.mock("../lib/trpc.ts", () => ({ status: { invalidate: mockInvalidateStatus, }, + alerts: { + invalidate: mockInvalidateAlerts, + }, }, }), }, @@ -283,6 +288,9 @@ describe("ProcessingStatusWidget", () => { expect( screen.getByText("Wahoo returned a server error. Reconnect Wahoo, then try again."), ).toBeTruthy(); + expect( + screen.queryByText("Try the update again. If it still fails, reconnect the data source."), + ).toBeNull(); expect(screen.getByRole("button", { name: "Dismiss Wahoo sync failure" })).toBeTruthy(); }); @@ -295,6 +303,7 @@ describe("ProcessingStatusWidget", () => { operationId: "00000000-0000-4000-8000-00000000f501", }); await waitFor(() => expect(mockInvalidateStatus).toHaveBeenCalledOnce()); + expect(mockInvalidateAlerts).toHaveBeenCalledOnce(); }); it("disables the dismiss button while the operation is pending", () => { diff --git a/packages/web/src/components/ProcessingStatusWidget.tsx b/packages/web/src/components/ProcessingStatusWidget.tsx index 05b325eeca..78d7c1e29f 100644 --- a/packages/web/src/components/ProcessingStatusWidget.tsx +++ b/packages/web/src/components/ProcessingStatusWidget.tsx @@ -70,6 +70,7 @@ export function ProcessingStatusWidget({ const dismissMutation = trpc.processing.dismiss.useMutation({ onSuccess: async () => { await trpcUtils.processing.status.invalidate(); + await trpcUtils.processing.alerts.invalidate(); }, }); if (loading && !data) { @@ -103,10 +104,6 @@ export function ProcessingStatusWidget({ datasets: data.datasets, operationKind: data.operations[0]?.kind, }); - const statusMessage = processingStatusMessage({ - status: data.overallStatus, - errorMessage: null, - }); const problemDatasets = data.datasets.filter( (dataset) => dataset.status === "failed" || dataset.status === "blocked", ); @@ -114,6 +111,13 @@ export function ProcessingStatusWidget({ datasets: data.datasets, operations: data.operations, }); + const statusMessage = + failureGroups.length > 0 + ? null + : processingStatusMessage({ + status: data.overallStatus, + errorMessage: null, + }); const hasFailureStatus = data.overallStatus === "failed" || data.overallStatus === "blocked"; const inProgressDatasets = data.datasets.filter((dataset) => ["active", "partial", "waiting", "delayed"].includes(dataset.status), From 8e081be2893c02133e207712c780f33a42ec0ecb Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 09:42:13 -0700 Subject: [PATCH 26/46] Add Paseo workspace scripts and lifecycle config (#2469) * chore: configure Paseo workspace workflows * fix: allow alertable in spell check * fix: scope mutation checks to pull request changes * test: align mobile tRPC mocks * test: align web tRPC mocks * fix: sync mobile Expo dependencies * docs: record CI incident findings --- .github/workflows/test.yml | 4 +- cspell.json | 1 + docs/production-incident-baseline.md | 30 ++ .../app-tests/(tabs)/activities.test.tsx | 1 + .../mobile/app-tests/(tabs)/index.test.tsx | 1 + .../mobile/app-tests/(tabs)/strain.test.tsx | 1 + .../mobile/app-tests/providers/[id].test.tsx | 1 + .../mobile/app-tests/providers/index.test.tsx | 1 + packages/mobile/package.json | 22 +- .../src/components/DataSourcesPanel.test.tsx | 1 + .../TimeRangeSelectorConsumers.tsx | 1 + .../web/src/pages/ActivitiesPage.test.tsx | 4 + packages/web/src/pages/Dashboard.test.tsx | 2 + paseo.json | 53 ++ pnpm-lock.yaml | 500 +++++++++--------- pnpm-workspace.yaml | 42 +- 16 files changed, 380 insertions(+), 285 deletions(-) create mode 100644 paseo.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31c557254d..ef11c8f35f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1064,7 +1064,9 @@ jobs: - name: Compute changed files and split into shards id: shard run: | - if [ "${{ github.event_name }}" = "pull_request" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event_name }}" = "pull_request" ]; then + DIFF_RANGE="${{ github.event.pull_request.base.sha }}...HEAD" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then DIFF_RANGE="origin/main...HEAD" else DIFF_RANGE="HEAD~1...HEAD" diff --git a/cspell.json b/cspell.json index 5916da625c..2da8f2d420 100644 --- a/cspell.json +++ b/cspell.json @@ -19,6 +19,7 @@ "words": [ "ABCDEFGHJKLMNPQRSTUVWXYZ", "Invalidatable", + "alertable", "refetches", "unlogged", "CPLUSPLUSFLAGS", diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index e5170c0d30..d37d91b557 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -7,6 +7,36 @@ full incident log or a replacement for runbooks. Use it to build shared memory about the kinds of issues this system encounters, the signals that identified them, and the durability work they suggest. +## 2026-08-10: Pull-request CI failures from configuration and test drift + +- **Status:** Repository fixes are pushed; the latest CI run has no failed + checks, but its iOS and watchOS native-build jobs remain queued for a + GitHub-hosted macOS runner. +- **Symptoms / user impact:** Pull-request CI initially failed spell check, + mutation testing, mobile tests, web unit tests, and the mobile Metro job. + The PR could not reach a completed green CI gate. +- **Evidence:** The [latest CI run](https://github.com/Asherlc/dofek/actions/runs/31406513226) + reports 81 successful checks and 4 skipped checks; only the iOS and watchOS + native-build jobs are queued. Local validation passed the full unit tier + (15,280 tests), mobile tests (1,486 tests), the focused web tests (68 tests), + and cspell. +- **Root causes:** The spell dictionary omitted the existing word + `alertable`; mutation preparation compared pull requests against + `origin/main` instead of the actual pull-request base SHA; several web and + mobile tRPC fixtures had not added the current processing dismissal + mutation; and Expo SDK 57 dependencies were one patch behind the installed + SDK compatibility set. +- **Fix:** Added the dictionary entry, scoped mutation diffs to + `github.event.pull_request.base.sha`, aligned the stale test fixtures, and + synchronized the Expo manifest and lockfile with `pnpm expo install --fix`. + The fixes are in commits [`2afe007`](https://github.com/Asherlc/dofek/commit/2afe0078cc271da42d21e6eea1b9ba33afbf8f80), + [`82cf19f`](https://github.com/Asherlc/dofek/commit/82cf19f432c8264f7cbe7808fd663f76557f5680), + [`ef8810d`](https://github.com/Asherlc/dofek/commit/ef8810d463ec628d1347422da8d667267b3c796e), + [`97671b6`](https://github.com/Asherlc/dofek/commit/97671b64e6a6f2a33527a7011cb3c5c3d18ab6d6), + and [`a7ef077`](https://github.com/Asherlc/dofek/commit/a7ef077e3c731818bf8ff2ad64c7b03647ce1e49). +- **Remaining risk / follow-up:** Confirm the two queued native jobs complete; + no code-level CI failure remains in the current run. + ## 2026-08-07 — Wahoo OAuth callback served as `Not Found` - **Status:** Root cause identified; the PWA update fix is implemented in this diff --git a/packages/mobile/app-tests/(tabs)/activities.test.tsx b/packages/mobile/app-tests/(tabs)/activities.test.tsx index 5608184d5f..027d03db3d 100644 --- a/packages/mobile/app-tests/(tabs)/activities.test.tsx +++ b/packages/mobile/app-tests/(tabs)/activities.test.tsx @@ -122,6 +122,7 @@ vi.mock("../../lib/trpc", () => ({ return mockDataHealthQuery; }, }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, useUtils: () => ({ calendar: { diff --git a/packages/mobile/app-tests/(tabs)/index.test.tsx b/packages/mobile/app-tests/(tabs)/index.test.tsx index 997642735f..b390e635fb 100644 --- a/packages/mobile/app-tests/(tabs)/index.test.tsx +++ b/packages/mobile/app-tests/(tabs)/index.test.tsx @@ -105,6 +105,7 @@ vi.mock("../../lib/trpc", () => ({ }; }, }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, triggerSync: { useMutation: () => ({ mutate: vi.fn(), diff --git a/packages/mobile/app-tests/(tabs)/strain.test.tsx b/packages/mobile/app-tests/(tabs)/strain.test.tsx index 1333c563e4..c2e3531b77 100644 --- a/packages/mobile/app-tests/(tabs)/strain.test.tsx +++ b/packages/mobile/app-tests/(tabs)/strain.test.tsx @@ -169,6 +169,7 @@ vi.mock("../../lib/trpc", () => ({ status: { useQuery: () => ({ data: undefined, isLoading: false, error: null }), }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, useUtils: () => ({ mobileDashboard: { diff --git a/packages/mobile/app-tests/providers/[id].test.tsx b/packages/mobile/app-tests/providers/[id].test.tsx index 9e94e7409c..3874792bc3 100644 --- a/packages/mobile/app-tests/providers/[id].test.tsx +++ b/packages/mobile/app-tests/providers/[id].test.tsx @@ -310,6 +310,7 @@ vi.mock("../../lib/trpc", () => ({ trpc: { processing: { status: { useQuery: (...args: unknown[]) => mockDataHealthQuery(...args) }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, sync: { providers: { useQuery: (...args: unknown[]) => mockProvidersQuery(...args) }, diff --git a/packages/mobile/app-tests/providers/index.test.tsx b/packages/mobile/app-tests/providers/index.test.tsx index 1ca3aa3b1f..2289d5ad12 100644 --- a/packages/mobile/app-tests/providers/index.test.tsx +++ b/packages/mobile/app-tests/providers/index.test.tsx @@ -322,6 +322,7 @@ vi.mock("../../lib/trpc", () => ({ trpc: { processing: { status: { useQuery: (...args: unknown[]) => mockDataHealthQuery(...args) }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, sync: { providers: { useQuery: (...args: unknown[]) => mockProvidersQuery(...args) }, diff --git a/packages/mobile/package.json b/packages/mobile/package.json index ce61d3169e..6ecb759a38 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -38,7 +38,7 @@ "@dofek/stats": "workspace:*", "@dofek/training": "workspace:*", "@dofek/zones": "workspace:*", - "@expo/metro-runtime": "57.0.8", + "@expo/metro-runtime": "57.0.9", "@expo/vector-icons": "15.1.1", "@formatjs/intl-getcanonicallocales": "3.2.11", "@formatjs/intl-locale": "5.3.9", @@ -59,25 +59,25 @@ "@trpc/client": "11.18.0", "@trpc/react-query": "11.18.0", "dofek-server": "workspace:*", - "expo": "57.0.11", + "expo": "57.0.12", "expo-apple-authentication": "57.0.1", - "expo-build-properties": "57.0.9", + "expo-build-properties": "57.0.10", "expo-camera": "57.0.3", "expo-crypto": "57.0.1", - "expo-dev-client": "57.0.10", + "expo-dev-client": "57.0.11", "expo-document-picker": "57.0.1", "expo-file-system": "57.0.2", "expo-haptics": "57.0.1", "expo-linking": "57.0.5", - "expo-location": "57.0.8", + "expo-location": "57.0.9", "expo-modules-core": "57.0.10", - "expo-notifications": "57.0.9", - "expo-router": "57.0.11", + "expo-notifications": "57.0.10", + "expo-router": "57.0.12", "expo-secure-store": "57.0.1", - "expo-sharing": "57.0.10", - "expo-splash-screen": "57.0.5", - "expo-task-manager": "57.0.8", - "expo-updates": "57.0.12", + "expo-sharing": "57.0.11", + "expo-splash-screen": "57.0.6", + "expo-task-manager": "57.0.9", + "expo-updates": "57.0.13", "expo-web-browser": "57.0.2", "posthog-react-native": "4.61.2", "react": "19.2.3", diff --git a/packages/web/src/components/DataSourcesPanel.test.tsx b/packages/web/src/components/DataSourcesPanel.test.tsx index 681e72128b..225a95a888 100644 --- a/packages/web/src/components/DataSourcesPanel.test.tsx +++ b/packages/web/src/components/DataSourcesPanel.test.tsx @@ -80,6 +80,7 @@ vi.mock("../lib/trpc.ts", () => ({ trpc: { processing: { status: { useQuery: mockDataHealthQuery }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, sync: { providers: { diff --git a/packages/web/src/components/test-helpers/TimeRangeSelectorConsumers.tsx b/packages/web/src/components/test-helpers/TimeRangeSelectorConsumers.tsx index b0eb1c9614..7843aac8e1 100644 --- a/packages/web/src/components/test-helpers/TimeRangeSelectorConsumers.tsx +++ b/packages/web/src/components/test-helpers/TimeRangeSelectorConsumers.tsx @@ -204,6 +204,7 @@ vi.mock("../../lib/trpc.ts", () => { datasets: [], operations: [], }), + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, }, }; diff --git a/packages/web/src/pages/ActivitiesPage.test.tsx b/packages/web/src/pages/ActivitiesPage.test.tsx index 372a66a033..73525cd601 100644 --- a/packages/web/src/pages/ActivitiesPage.test.tsx +++ b/packages/web/src/pages/ActivitiesPage.test.tsx @@ -134,6 +134,7 @@ vi.mock("../lib/trpc.ts", () => ({ return mockDataHealthQuery; }, }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, useUtils: () => ({ calendar: { @@ -143,6 +144,9 @@ vi.mock("../lib/trpc.ts", () => ({ activity: { list: { invalidate: invalidateActivityList }, }, + processing: { + status: { invalidate: vi.fn() }, + }, }), }, })); diff --git a/packages/web/src/pages/Dashboard.test.tsx b/packages/web/src/pages/Dashboard.test.tsx index 3f524dd87d..e465ab1f12 100644 --- a/packages/web/src/pages/Dashboard.test.tsx +++ b/packages/web/src/pages/Dashboard.test.tsx @@ -141,7 +141,9 @@ vi.mock("../lib/trpc.ts", () => ({ }, processing: { status: { useQuery: mockDataHealthQuery }, + dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, }, + useUtils: () => ({ processing: { status: { invalidate: vi.fn() } } }), }, })); diff --git a/paseo.json b/paseo.json new file mode 100644 index 0000000000..94d08b25e6 --- /dev/null +++ b/paseo.json @@ -0,0 +1,53 @@ +{ + "scripts": { + "server": { + "type": "service", + "command": "PORT=\"$PASEO_PORT\" mise exec -- pnpm --filter dofek-server dev" + }, + "web": { + "type": "service", + "command": "mise exec -- pnpm --filter dofek-web dev --host \"$HOST\" --port \"$PASEO_PORT\"" + }, + "worker": { + "command": "mise exec -- pnpm dev" + }, + "mobile": { + "command": "mise exec -- pnpm --filter dofek-mobile start" + }, + "storybook-web": { + "type": "service", + "command": "mise exec -- pnpm --filter dofek-web storybook -- --host \"$HOST\" --port \"$PASEO_PORT\"" + }, + "storybook-mobile-web": { + "type": "service", + "command": "mise exec -- pnpm --dir packages/mobile exec storybook dev --config-dir .storybook --host \"$HOST\" --port \"$PASEO_PORT\"" + }, + "doctor": { + "command": "mise run doctor" + }, + "test": { + "command": "mise exec -- pnpm test" + } + }, + "worktree": { + "setup": "command -v mise >/dev/null || { echo 'mise is required: https://mise.jdx.dev/getting-started.html' >&2; exit 1; }; export MISE_LOCKED=1; mise install --locked && mise run cloud:prebuild", + "teardown": "mise exec -- pnpm tsx scripts/conductor-archive.ts", + "servicePorts": { + "range": "51000-51999" + } + }, + "metadataGeneration": { + "title": { + "instructions": "Use a concise, imperative title that describes the user-visible outcome." + }, + "branchName": { + "instructions": "Use a short kebab-case branch name based on the change." + }, + "commitMessage": { + "instructions": "Use a Conventional Commit message that states the durable change." + }, + "pullRequest": { + "instructions": "Monitor CI. If a job fails, fix the root cause, commit, push, and keep checking. Monitor for review comments, address all actionable comments, resolve conflicts, and do not stop until CI is green and the PR is ready for review." + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85f550bd1..60d9e92808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -395,7 +395,7 @@ importers: dependencies: '@bacons/apple-targets': specifier: 4.0.7 - version: 4.0.7(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3) + version: 4.0.7(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3) '@dofek/auth': specifier: workspace:* version: link:../auth @@ -427,8 +427,8 @@ importers: specifier: workspace:* version: link:../zones '@expo/metro-runtime': - specifier: 57.0.8 - version: 57.0.8(@expo/log-box@57.0.2)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) + specifier: 57.0.9 + version: 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) '@expo/vector-icons': specifier: 15.1.1 version: 15.1.1(expo-font@57.0.1)(react-native@0.86.2)(react@19.2.3) @@ -464,13 +464,13 @@ importers: version: 2.2.0(react-native@0.86.2) '@react-native-community/datetimepicker': specifier: 9.1.0 - version: 9.1.0(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + version: 9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@sentry/cli': specifier: 3.6.1 version: 3.6.1 '@sentry/react-native': specifier: 8.20.0 - version: 8.20.0(@expo/env@2.4.2)(dotenv@16.6.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + version: 8.20.0(@expo/env@2.4.2)(dotenv@16.6.1)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@tanstack/query-async-storage-persister': specifier: 5.101.2 version: 5.101.2 @@ -490,65 +490,65 @@ importers: specifier: workspace:* version: link:../server expo: - specifier: 57.0.11 - version: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + specifier: 57.0.12 + version: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-apple-authentication: specifier: 57.0.1 - version: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + version: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) expo-build-properties: - specifier: 57.0.9 - version: 57.0.9(expo@57.0.11) + specifier: 57.0.10 + version: 57.0.10(expo@57.0.12) expo-camera: specifier: 57.0.3 - version: 57.0.3(@types/emscripten@1.41.5)(expo@57.0.11)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3) + version: 57.0.3(@types/emscripten@1.41.5)(expo@57.0.12)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3) expo-crypto: specifier: 57.0.1 - version: 57.0.1(expo@57.0.11) + version: 57.0.1(expo@57.0.12) expo-dev-client: - specifier: 57.0.10 - version: 57.0.10(expo@57.0.11)(react-native@0.86.2) + specifier: 57.0.11 + version: 57.0.11(expo@57.0.12)(react-native@0.86.2) expo-document-picker: specifier: 57.0.1 - version: 57.0.1(expo@57.0.11) + version: 57.0.1(expo@57.0.12) expo-file-system: specifier: 57.0.2 - version: 57.0.2(expo@57.0.11)(react-native@0.86.2) + version: 57.0.2(expo@57.0.12)(react-native@0.86.2) expo-haptics: specifier: 57.0.1 - version: 57.0.1(expo@57.0.11) + version: 57.0.1(expo@57.0.12) expo-linking: specifier: 57.0.5 - version: 57.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + version: 57.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) expo-location: - specifier: 57.0.8 - version: 57.0.8(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3) + specifier: 57.0.9 + version: 57.0.9(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3) expo-modules-core: specifier: 57.0.10 version: 57.0.10(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) expo-notifications: - specifier: 57.0.9 - version: 57.0.9(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + specifier: 57.0.10 + version: 57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-router: - specifier: 57.0.11 - version: 57.0.11(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.8)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.11)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + specifier: 57.0.12 + version: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) expo-secure-store: specifier: 57.0.1 - version: 57.0.1(expo@57.0.11) + version: 57.0.1(expo@57.0.12) expo-sharing: - specifier: 57.0.10 - version: 57.0.10(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + specifier: 57.0.11 + version: 57.0.11(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-splash-screen: - specifier: 57.0.5 - version: 57.0.5(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3) + specifier: 57.0.6 + version: 57.0.6(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3) expo-task-manager: - specifier: 57.0.8 - version: 57.0.8(expo@57.0.11)(react-native@0.86.2) + specifier: 57.0.9 + version: 57.0.9(expo@57.0.12)(react-native@0.86.2) expo-updates: - specifier: 57.0.12 - version: 57.0.12(expo-dev-client@57.0.10)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + specifier: 57.0.13 + version: 57.0.13(expo-dev-client@57.0.11)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) expo-web-browser: specifier: 57.0.2 - version: 57.0.2(expo@57.0.11)(react-native@0.86.2) + version: 57.0.2(expo@57.0.12)(react-native@0.86.2) posthog-react-native: specifier: 4.61.2 version: 4.61.2(@react-native-async-storage/async-storage@2.2.0)(expo-application@57.0.2)(expo-file-system@57.0.2)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4) @@ -2659,8 +2659,8 @@ packages: '@expo-google-fonts/material-symbols@0.4.38': resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} - '@expo/cli@57.0.13': - resolution: {integrity: sha512-8gjLMyx+s0dLeDHlcfjM9D9x5yrCU5C6516rmC7q/Wiyuj1fxgr/cbDSmjdpQKkjlvvfvNwtRyMk2zhvhPohiw==} + '@expo/cli@57.0.14': + resolution: {integrity: sha512-yu3sie3cDPDXBTUMvivqguW+lT8jnLIF8Q6e75MRaq7BoAzihor0KrJwtXAtC4ep5eOYYC1otEvtOuctG1xf8Q==} hasBin: true peerDependencies: expo: '*' @@ -2675,17 +2675,14 @@ packages: '@expo/code-signing-certificates@0.0.6': resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} - '@expo/config-plugins@57.0.6': - resolution: {integrity: sha512-7CmKrS5Rnu8aSZyNlxH2qzA7Ls1HEa4EQvEVOAkHDKPr1e4Cg/nz7I7dUl09QDTVjMvkKYDH4Th0DuAsgqASaw==} - '@expo/config-plugins@57.0.7': resolution: {integrity: sha512-jvXMiNuH8W7fmU9yCk4/jVwDX2G/5rWUg5PZ22mriccEeQVS9HJjQiUHivMaK6MxEG5L9f0RPScxe/nQfnpQvg==} '@expo/config-types@57.0.2': resolution: {integrity: sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==} - '@expo/config@57.0.6': - resolution: {integrity: sha512-VpMJpB/De/fb9bBFVVBiK6Ntg9lt0kAleLH9hcZz85CYRUQ3jVFVA8rNC5f8y4cp2+FiiPNFp62+kEOFI6pDiw==} + '@expo/config@57.0.7': + resolution: {integrity: sha512-4A+V8x5OmQqNm76l84S+RrB6kVoeFrvcm/Xn/6d+ELPF/HeucDheAFchdYxYNy+NEvWzuNlI/oCvrftIeK+dbQ==} '@expo/devcert@1.2.1': resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} @@ -2715,8 +2712,8 @@ packages: '@expo/expo-modules-macros-plugin@0.6.1': resolution: {integrity: sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==} - '@expo/fingerprint@0.20.6': - resolution: {integrity: sha512-cmC/6BOPRbdKr77Mgjwszb8aM0hY2RKBpMRCmjSdn9zIcn2FGor/ic4fHVr46cQFa1G6RDGg1GyAjRw3US4CCQ==} + '@expo/fingerprint@0.20.7': + resolution: {integrity: sha512-tYyZD4XZSn1C30pr9IvjN/BjAqpf6r9e1NL09lPvheO1DMLByOVdmMHFLSwW8Pu6veVgzue7GDWmq9P8mc8GMg==} hasBin: true '@expo/image-utils@0.11.1': @@ -2725,14 +2722,14 @@ packages: '@expo/image-utils@0.11.4': resolution: {integrity: sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw==} - '@expo/inline-modules@0.1.4': - resolution: {integrity: sha512-8bPSCm//dv8raYfrQ4x79rCX52vMw0QzmnYYl4eSOAvEbGvDPPRGGdbrhZZ7oihU+hI1sZNS5kYEgqODgFwfsw==} + '@expo/inline-modules@0.1.5': + resolution: {integrity: sha512-LC+kWeIwnvsGIvDaFBd8uzleWzWZiZTCG7CmtfxBLjW/Gify596X67ZqTi76iHzm5KToAfwwX0nppAKPDA9vQw==} '@expo/json-file@11.0.1': resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} - '@expo/local-build-cache-provider@57.0.5': - resolution: {integrity: sha512-OwiNC0Uxu67TOH7TEQ94GLa4oNzNfbbItKGdy3QMfX+hCSZv2VvjcjRo5vttMHfXCnXa9KZIu3GBS9pvtDHWhA==} + '@expo/local-build-cache-provider@57.0.6': + resolution: {integrity: sha512-6aFMROb1SzIvrefpwhgS5QGNELU9T2lpK0Hcl6oiZ4/mbKgZRk0WEPauo/dHH6IgDmyK25InCMHF5nj7XY4LWg==} '@expo/log-box@57.0.2': resolution: {integrity: sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw==} @@ -2742,8 +2739,8 @@ packages: react: 19.2.3 react-native: '*' - '@expo/metro-config@57.0.7': - resolution: {integrity: sha512-bVfEkg4zF1cA62OqAdYXmFOooJ6TB/I+REi7Se6Ct+PbSC+89TwSqWXnYx34L08eIs4z+1ilgbATakTZpgefmQ==} + '@expo/metro-config@57.0.8': + resolution: {integrity: sha512-cZOVjbljqRBMCXcloc5k23gsFOhWdiKXhDkp5jU/wY6IXR6G7cYtNOwxKfxI1QLhs0KcXY0gDmZ2UIueoHzY7g==} peerDependencies: expo: '*' peerDependenciesMeta: @@ -2753,8 +2750,8 @@ packages: '@expo/metro-file-map@57.0.1': resolution: {integrity: sha512-8JXfVstZN7QnP4NianZZnlTVboOWR0sG8trUDNajOjnbGlPln29vponXM84tY+3tAHapz5/TxE53L0ixUwqPtA==} - '@expo/metro-runtime@57.0.8': - resolution: {integrity: sha512-RrdehKXNtWpnm8nNs1QFhS0IauyMKR/nSQiry6dcUJs2T5EH8gYDMcMegUt9yI41K4nO3W8tr3O0xd/GZFObXQ==} + '@expo/metro-runtime@57.0.9': + resolution: {integrity: sha512-gtly6wOk59Ip7S5NtYSYMmMmz7VyKeNWo/dERF7b1q3Rmek4fBwEV5tTHIXaDTKboAryXdWR9gjSv1DzErP/Dg==} peerDependencies: '@expo/log-box': ^57.0.2 expo: '*' @@ -2781,8 +2778,8 @@ packages: '@expo/plist@0.8.1': resolution: {integrity: sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==} - '@expo/prebuild-config@57.0.10': - resolution: {integrity: sha512-myrS5NolFAQWD8g7QuqkettnkyJx3GRl603N6rlmqAMxmO5EU7sZu4EX2EsIKkva8QtpX84B0E17PsM9xXMsTQ==} + '@expo/prebuild-config@57.0.11': + resolution: {integrity: sha512-GcBX2xQ6l4VrkxmlAXm6lVUeHhnURe0Jh86hNVQqoYYIFflUx4IFmMm9B9bN0mbp/Jb+j+CGjkSPQcIYq9NM/Q==} '@expo/require-utils@57.0.4': resolution: {integrity: sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw==} @@ -2827,8 +2824,8 @@ packages: '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} - '@expo/ui@57.0.9': - resolution: {integrity: sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw==} + '@expo/ui@57.0.10': + resolution: {integrity: sha512-cYVo6R6JmJgza2p1jyE1lGfNPWncHJGWRxhTyOi+pLRAAdiwo8Z2LcdaArewEXVUwJTQhczLRxeXGL0i99NUwQ==} peerDependencies: '@babel/core': '*' expo: '*' @@ -10598,15 +10595,15 @@ packages: peerDependencies: expo: '*' - expo-asset@57.0.9: - resolution: {integrity: sha512-FXlwwW5ThJ2kwXqVX0VcYcDrbmzPDUGPYJOuQZYfsdVB+TLA8LmOgU0Y5ykzLmLs5ONiqs/YjT6Uubv1NUQFzg==} + expo-asset@57.0.10: + resolution: {integrity: sha512-32QpNkWlb8ftxq3ClAriwFXcZlpzuP7Qx4z3Gc9vhbAm1QZzGsCN+PwYt3fN8W46HBw5qaI6DSXcAyBlYzeVqA==} peerDependencies: expo: '*' react: 19.2.3 react-native: '*' - expo-build-properties@57.0.9: - resolution: {integrity: sha512-IX8Nz85yNDZyqBK7zbLEfn4ijMGaF6TmLjCY2KE0UKltUoV78DhgN6ksPZbDbPDvqLpU2fMY/5OD4CbYB7hHwg==} + expo-build-properties@57.0.10: + resolution: {integrity: sha512-twFB1IyA1WM3hGUdnEGTWqBsmdtQMIx6SBozlmhQRnocX9Kb8fDTMAAQOsjB3fqJeXUprIeuCpO+gO3MldJ7tQ==} peerDependencies: expo: '*' @@ -10621,6 +10618,12 @@ packages: react-native-web: optional: true + expo-constants@57.0.10: + resolution: {integrity: sha512-GCDXYEsloBfouMdT3BzoGhAkcLnYxEFNLQoSbNKIvIrD9FY5MmSeWuvRSveJdOiuydwQY2iH6hy020vTaQflCQ==} + peerDependencies: + expo: '*' + react-native: '*' + expo-constants@57.0.9: resolution: {integrity: sha512-Y47sGiF+U8fwicUSPdJPjB27PuU+FgLK4Mpai0ksZF5hv8jNN3HBqKuDNxsiu70uHBKf80TaR4gwMPh0pmETiQ==} peerDependencies: @@ -10632,13 +10635,13 @@ packages: peerDependencies: expo: '*' - expo-dev-client@57.0.10: - resolution: {integrity: sha512-aY6PVbD1R+XwIcrOucRnB/yJfB6qVHIHqgRV6F8LwOzo7DwZaa/ixs+O2itHH7BNbUiwkS8tFjtbzJ4vDx/ulw==} + expo-dev-client@57.0.11: + resolution: {integrity: sha512-IHatS97Vl0eQT8IG1Tsc10qDoyItxwjOw9AXLAbub+3uU7KljcqnXiCWRlhsI5KTjWV9/Dboc8psgiRb7oXXQw==} peerDependencies: expo: '*' - expo-dev-launcher@57.0.10: - resolution: {integrity: sha512-LfGfiKDBzVgCXbIozbyzPU7yLdOtvmuH2U9jJbLEmkJxoExOy8NiFJoL8U+I/pYw3tj4I3GVkVOtZtItDFGn8Q==} + expo-dev-launcher@57.0.11: + resolution: {integrity: sha512-PpYoSmtMkvLYG9xVVHv8ZDMMsHR0D5ax9lFTgy+e6WT98NLUTwTexlObOkjCB80cfiGaKfmOZEKyXRiec3Lgag==} peerDependencies: expo: '*' react-native: '*' @@ -10648,8 +10651,8 @@ packages: peerDependencies: expo: '*' - expo-dev-menu@57.0.10: - resolution: {integrity: sha512-y9S8J2MfqEO1T6daH3HNsjR7S4TcLf3CpSvNAwSzj5x6wmJoMYEvxWkzKy7Ep1R21EFBeSET1sZ1mFCCCYjgBw==} + expo-dev-menu@57.0.11: + resolution: {integrity: sha512-XY+rT7JALQAMBVYLLGfTwZ/E9igLDtUnyCPox1xxWaH6n7T3LJyxVb5unoORYhF4HzHQoxxeeHd6hg5P4AfyrQ==} peerDependencies: expo: '*' react-native: '*' @@ -10702,8 +10705,8 @@ packages: react: 19.2.3 react-native: '*' - expo-location@57.0.8: - resolution: {integrity: sha512-XtELTtjm1LQ89r7meE63pxRu0IKli1+rYTem6xNr3u2ZIYwXNzyehxsW+65Fc86qgK2P6RUNdt1AkNj+U6+Q2g==} + expo-location@57.0.9: + resolution: {integrity: sha512-NfTBIgpHDagevd8gz3zSU2dcA+2KjDZPsK8/A57BPKeApcgFzh44QpzYDgSOc01sLNa3DxIsgQSWdSXXpj7IXg==} peerDependencies: expo: '*' @@ -10741,21 +10744,21 @@ packages: peerDependencies: react-native: '*' - expo-notifications@57.0.9: - resolution: {integrity: sha512-AeA4sdgvfeyw5/O+JPekCIex8JfoBO21fABfZCKAeXF19pWaVtXva08dqwF41JW/LWAmvrshCAH36gP+jdVD4Q==} + expo-notifications@57.0.10: + resolution: {integrity: sha512-Zrwwd2eGzSuk3LyD01P5QzhiE/oXNNWaUq/NLQnPoeo63Ek2R6sWA00b0o0rl47uIqdmMtuQUfWbXgJJyc3Uag==} peerDependencies: expo: '*' react: 19.2.3 react-native: '*' - expo-router@57.0.11: - resolution: {integrity: sha512-kE2hz4lLkddZ94vN4nmbq5aXeo0t6FaZ8xJn/Hyn8/dQPsGlvDK0j4ZVayEUUdNmkNHsBVihgf1bl/2rCsYEzA==} + expo-router@57.0.12: + resolution: {integrity: sha512-vA+RUSzMwHmWa/pQpoYqJoTbhIQaX/OzvMvOlWVBeMflF1RC40zCzqLiXMKimWfEUh/G6ITnz31EtpNKUghxQw==} peerDependencies: '@expo/log-box': ^57.0.2 - '@expo/metro-runtime': ^57.0.8 + '@expo/metro-runtime': ^57.0.9 '@testing-library/react-native': '>= 13.2.0' expo: '*' - expo-constants: ^57.0.9 + expo-constants: ^57.0.10 expo-linking: ^57.0.5 react: 19.2.3 react-dom: 19.2.3 @@ -10785,19 +10788,19 @@ packages: peerDependencies: expo: '*' - expo-server@57.0.1: - resolution: {integrity: sha512-sBfVDH6dmKVHZxqUxbfkzS00PZELMZt1IpnHKxcOTMZtR/t7CtRAFrbXcisG+EyzeqHSVDacZT+1tbYfZt5D8w==} + expo-server@57.0.2: + resolution: {integrity: sha512-GzfiSHC19xU7I0Dq4O/7DOtWdmc14vynpxBb9nWDRvsF+7RjoSIdkVePFhx4Qm6ILFNbo6KxFGu95QnDdKxUdw==} engines: {node: '>=20.16.0'} - expo-sharing@57.0.10: - resolution: {integrity: sha512-WvZSgY96NIR3ZHEud5T4K5Q+OK+WW7PGN8CZ1zIYIhxlwKlMOf9qGNCYVDY3pMPU8FK7n+gbiguGxulWEXot6w==} + expo-sharing@57.0.11: + resolution: {integrity: sha512-Ukp6w/5bMnYkBAfNhINX6BgrcIoTZWArzUXiJZzvA22/hIPAGuQeSJLqrgeXHA0/2JP1A3PHH0Sls9OK7d4ecw==} peerDependencies: expo: '*' react: 19.2.3 react-native: '*' - expo-splash-screen@57.0.5: - resolution: {integrity: sha512-ZN0LDXlhHRNFjXTYZDojXk8IfaoUIu7qa3hhoBTXgyj1UB/iewGlH6+M3Nvhun2lY2d/+xhwqMhv0hIRoBo09Q==} + expo-splash-screen@57.0.6: + resolution: {integrity: sha512-FY0E7hMyXVAsNh18yGzIjdooPXdjafalEb6mk2EcqNPjXspHuu+pVEhUscEunw4wF8F/w6MOpT7UfOwHVWez9g==} peerDependencies: expo: '*' @@ -10812,8 +10815,8 @@ packages: react: 19.2.3 react-native: '*' - expo-task-manager@57.0.8: - resolution: {integrity: sha512-YLTU27QeDEGWA2s71fVevovI9Y1iXnQTypisOGQBmOyPUwKITz4jvBJpye7HCxblKbG3pOkK48SI/YsZdyyUiQ==} + expo-task-manager@57.0.9: + resolution: {integrity: sha512-98L0EIexQkAxJ1GKPAev9rJcEJvZDfZOF8vmanvXisvfpC/Z0Rsel4vzV1bVldzXwQ4rMBu3V5C9evFJQMh1PA==} peerDependencies: expo: '*' react-native: '*' @@ -10823,8 +10826,8 @@ packages: peerDependencies: expo: '*' - expo-updates@57.0.12: - resolution: {integrity: sha512-ZFsW8Mi9qFrrYPSXF++1FXejjflRdgbVPzaSJJATuHelVmIIb3D98gCO9sIsaLPHO1RCQVj1ltkj51VnwVL+4g==} + expo-updates@57.0.13: + resolution: {integrity: sha512-Wi93vaZ4e3kjfHb0J7RZU5PwrGncDC9T7R0C525vVs2Oa8cGSephRWMXa3+PsgAZ9mTAEh0XQoW6Yht/1ilYnA==} hasBin: true peerDependencies: expo: '*' @@ -10841,8 +10844,8 @@ packages: expo: '*' react-native: '*' - expo@57.0.11: - resolution: {integrity: sha512-R97257N39Dw0kQFuI4/RvYx95GQ+dmePdo8hxcMOjDxAT4VcCckjILJeAWCE19Jxjb92hZ5NDXAfDPkkV1RB9w==} + expo@57.0.12: + resolution: {integrity: sha512-sVgXaMjh5uapBvBkik3QibxbKI2g1zNtNeHntjRfixWAI3tlQZbaK4ACdInY4+jUBVymkf8u9BXJUaSes9jdtA==} hasBin: true peerDependencies: '@expo/dom-webview': 55.0.5 @@ -17860,13 +17863,13 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@bacons/apple-targets@4.0.7(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3)': + '@bacons/apple-targets@4.0.7(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@bacons/xcode': 1.0.0-alpha.32(supports-color@11.0.0) '@expo/image-utils': 0.11.1(supports-color@11.0.0)(typescript@6.0.3) '@react-native/normalize-colors': 0.79.7 debug: 4.4.3(supports-color@11.0.0) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) glob: 10.5.0 transitivePeerDependencies: - supports-color @@ -18420,26 +18423,26 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@57.0.13(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-router@57.0.11)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/cli@57.0.14(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-router@57.0.12)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/devcert': 1.2.1(supports-color@11.0.0) '@expo/env': 2.4.2(supports-color@11.0.0) '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) - '@expo/inline-modules': 0.1.4(supports-color@11.0.0)(typescript@6.0.3) + '@expo/inline-modules': 0.1.5(supports-color@11.0.0)(typescript@6.0.3) '@expo/json-file': 11.0.1 - '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@expo/metro': 56.0.0(supports-color@11.0.0) - '@expo/metro-config': 57.0.7(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3) + '@expo/metro-config': 57.0.8(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3) '@expo/metro-file-map': 57.0.1(supports-color@11.0.0) '@expo/osascript': 2.7.1 '@expo/package-manager': 1.13.1 '@expo/plist': 0.8.1(patch_hash=2e81ea41c32856973b677ca39a3f7f4a53b550dc5093fddfaa1abd120b800e2e) - '@expo/prebuild-config': 57.0.10(supports-color@11.0.0)(typescript@6.0.3) + '@expo/prebuild-config': 57.0.11(supports-color@11.0.0)(typescript@6.0.3) '@expo/require-utils': 57.0.4(supports-color@11.0.0)(typescript@6.0.3) - '@expo/router-server': 57.0.5(@expo/metro-runtime@57.0.8)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-router@57.0.11)(expo-server@57.0.1)(expo@57.0.11)(react-dom@19.2.3)(react@19.2.3)(supports-color@11.0.0) + '@expo/router-server': 57.0.5(@expo/metro-runtime@57.0.9)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-router@57.0.12)(expo-server@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react@19.2.3)(supports-color@11.0.0) '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.21.0) @@ -18456,8 +18459,8 @@ snapshots: connect: 3.7.0(supports-color@11.0.0) debug: 4.4.3(supports-color@11.0.0) dnssd-advertise: 1.1.6 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-server: 57.0.1 + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-server: 57.0.2 fetch-nodeshim: 0.4.10 getenv: 2.0.0 glob: 13.0.6 @@ -18482,7 +18485,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 57.0.11(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.8)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.11)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - '@expo/dom-webview' @@ -18501,25 +18504,6 @@ snapshots: dependencies: node-forge: 1.4.0 - '@expo/config-plugins@57.0.6(supports-color@11.0.0)(typescript@6.0.3)': - dependencies: - '@expo/config-types': 57.0.2 - '@expo/json-file': 11.0.1 - '@expo/plist': 0.8.1(patch_hash=2e81ea41c32856973b677ca39a3f7f4a53b550dc5093fddfaa1abd120b800e2e) - '@expo/require-utils': 57.0.4(supports-color@11.0.0)(typescript@6.0.3) - '@expo/sdk-runtime-versions': 1.0.0 - chalk: 4.1.2 - debug: 4.4.3(supports-color@11.0.0) - getenv: 2.0.0 - glob: 13.0.6 - semver: 7.8.5 - slugify: 1.6.9 - xcode: 3.0.1 - xml2js: 0.6.0 - transitivePeerDependencies: - - supports-color - - typescript - '@expo/config-plugins@57.0.7(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@expo/config-types': 57.0.2 @@ -18541,7 +18525,7 @@ snapshots: '@expo/config-types@57.0.2': {} - '@expo/config@57.0.6(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/config@57.0.7(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-types': 57.0.2 @@ -18571,9 +18555,9 @@ snapshots: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - '@expo/dom-webview@55.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)': + '@expo/dom-webview@55.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -18587,7 +18571,7 @@ snapshots: '@expo/expo-modules-macros-plugin@0.6.1': {} - '@expo/fingerprint@0.20.6(supports-color@11.0.0)': + '@expo/fingerprint@0.20.7(supports-color@11.0.0)': dependencies: '@expo/env': 2.4.2(supports-color@11.0.0) '@expo/spawn-async': 1.8.0 @@ -18629,7 +18613,7 @@ snapshots: - supports-color - typescript - '@expo/inline-modules@0.1.4(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/inline-modules@0.1.5(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: @@ -18641,29 +18625,29 @@ snapshots: '@babel/code-frame': 7.29.7 json5: 2.2.3 - '@expo/local-build-cache-provider@57.0.5(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/local-build-cache-provider@57.0.6(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@expo/config': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color - typescript - '@expo/log-box@57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)': + '@expo/log-box@57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: - '@expo/dom-webview': 55.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@expo/dom-webview': 55.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) anser: 1.4.10 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) stacktrace-parser: 0.1.11 - '@expo/metro-config@57.0.7(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/metro-config@57.0.8(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/generator': 7.29.7 - '@expo/config': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/env': 2.4.2(supports-color@11.0.0) '@expo/json-file': 11.0.1 '@expo/metro': 56.0.0(supports-color@11.0.0) @@ -18684,7 +18668,7 @@ snapshots: postcss: 8.5.23 resolve-from: 5.0.0 optionalDependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -18702,11 +18686,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@57.0.8(@expo/log-box@57.0.2)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)': + '@expo/metro-runtime@57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)': dependencies: - '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) anser: 1.4.10 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -18761,9 +18745,9 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@57.0.10(supports-color@11.0.0)(typescript@6.0.3)': + '@expo/prebuild-config@57.0.11(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@expo/config': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-types': 57.0.2 '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) @@ -18787,17 +18771,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@57.0.5(@expo/metro-runtime@57.0.8)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-router@57.0.11)(expo-server@57.0.1)(expo@57.0.11)(react-dom@19.2.3)(react@19.2.3)(supports-color@11.0.0)': + '@expo/router-server@57.0.5(@expo/metro-runtime@57.0.9)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-router@57.0.12)(expo-server@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react@19.2.3)(supports-color@11.0.0)': dependencies: debug: 4.4.3(supports-color@11.0.0) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) - expo-font: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) - expo-server: 57.0.1 + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) + expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) + expo-server: 57.0.2 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 57.0.8(@expo/log-box@57.0.2)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) - expo-router: 57.0.11(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.8)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.11)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + '@expo/metro-runtime': 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) + expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -18812,9 +18796,9 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@57.0.9(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.11)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)': + '@expo/ui@57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)': dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) sf-symbols-typescript: 2.2.0 @@ -18829,7 +18813,7 @@ snapshots: '@expo/vector-icons@15.1.1(expo-font@57.0.1)(react-native@0.86.2)(react@19.2.3)': dependencies: - expo-font: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -21522,13 +21506,13 @@ snapshots: merge-options: 3.0.4 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - '@react-native-community/datetimepicker@9.1.0(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)': + '@react-native-community/datetimepicker@9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: invariant: 2.2.4 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) '@react-native-community/slider@5.1.2': {} @@ -22125,7 +22109,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.43.0 '@sentry/core': 10.45.0 - '@sentry/react-native@8.20.0(@expo/env@2.4.2)(dotenv@16.6.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)': + '@sentry/react-native@8.20.0(@expo/env@2.4.2)(dotenv@16.6.1)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: '@sentry/babel-plugin-component-annotate': 5.3.0 '@sentry/browser': 10.67.0 @@ -22136,7 +22120,7 @@ snapshots: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: - '@expo/env' - dotenv @@ -24453,7 +24437,7 @@ snapshots: '@storybook/addon-ondevice-controls@10.5.3(@gorhom/bottom-sheet@5.2.8)(@react-native-community/datetimepicker@9.1.0)(@react-native-community/slider@5.1.2)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.86.2)(react@19.2.3) - '@react-native-community/datetimepicker': 9.1.0(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@react-native-community/datetimepicker': 9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@react-native-community/slider': 5.1.2 '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) @@ -25931,7 +25915,7 @@ snapshots: core-js: 2.6.12 regenerator-runtime: 0.10.5 - babel-preset-expo@57.0.6(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.11)(react-refresh@0.14.2)(supports-color@11.0.0): + babel-preset-expo@57.0.6(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.12)(react-refresh@0.14.2)(supports-color@11.0.0): dependencies: '@babel/generator': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@11.0.0) @@ -25978,7 +25962,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -27773,38 +27757,38 @@ snapshots: expect-type@1.4.0: {} - expo-apple-authentication@57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3): + expo-apple-authentication@57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-application@57.0.2(expo@57.0.11): + expo-application@57.0.2(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-asset@57.0.9(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): + expo-asset@57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - expo-build-properties@57.0.9(expo@57.0.11): + expo-build-properties@57.0.10(expo@57.0.12): dependencies: '@expo/schema-utils': 57.0.2 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@57.0.3(@types/emscripten@1.41.5)(expo@57.0.11)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3): + expo-camera@57.0.3(@types/emscripten@1.41.5)(expo@57.0.12)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3): dependencies: barcode-detector: 3.1.3(@types/emscripten@1.41.5) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: @@ -27812,85 +27796,93 @@ snapshots: transitivePeerDependencies: - '@types/emscripten' - expo-constants@57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0): + expo-constants@57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0): + dependencies: + '@expo/env': 2.4.2(supports-color@11.0.0) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + transitivePeerDependencies: + - supports-color + + expo-constants@57.0.9(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0): dependencies: '@expo/env': 2.4.2(supports-color@11.0.0) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - expo-crypto@57.0.1(expo@57.0.11): + expo-crypto@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-dev-client@57.0.10(expo@57.0.11)(react-native@0.86.2): + expo-dev-client@57.0.11(expo@57.0.12)(react-native@0.86.2): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-dev-launcher: 57.0.10(expo@57.0.11)(react-native@0.86.2) - expo-dev-menu: 57.0.10(expo@57.0.11)(react-native@0.86.2) - expo-dev-menu-interface: 57.0.0(expo@57.0.11) - expo-manifests: 57.0.1(expo@57.0.11) - expo-updates-interface: 57.0.1(expo@57.0.11) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-dev-launcher: 57.0.11(expo@57.0.12)(react-native@0.86.2) + expo-dev-menu: 57.0.11(expo@57.0.12)(react-native@0.86.2) + expo-dev-menu-interface: 57.0.0(expo@57.0.12) + expo-manifests: 57.0.1(expo@57.0.12) + expo-updates-interface: 57.0.1(expo@57.0.12) transitivePeerDependencies: - react-native - expo-dev-launcher@57.0.10(expo@57.0.11)(react-native@0.86.2): + expo-dev-launcher@57.0.11(expo@57.0.12)(react-native@0.86.2): dependencies: '@expo/schema-utils': 57.0.2 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-dev-menu: 57.0.10(expo@57.0.11)(react-native@0.86.2) - expo-manifests: 57.0.1(expo@57.0.11) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-dev-menu: 57.0.11(expo@57.0.12)(react-native@0.86.2) + expo-manifests: 57.0.1(expo@57.0.12) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-dev-menu-interface@57.0.0(expo@57.0.11): + expo-dev-menu-interface@57.0.0(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-dev-menu@57.0.10(expo@57.0.11)(react-native@0.86.2): + expo-dev-menu@57.0.11(expo@57.0.12)(react-native@0.86.2): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-dev-menu-interface: 57.0.0(expo@57.0.11) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-dev-menu-interface: 57.0.0(expo@57.0.12) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-document-picker@57.0.1(expo@57.0.11): + expo-document-picker@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-eas-client@57.0.1: {} - expo-file-system@57.0.2(expo@57.0.11)(react-native@0.86.2): + expo-file-system@57.0.2(expo@57.0.12)(react-native@0.86.2): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-font@57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3): + expo-font@57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) fontfaceobserver: 2.3.0 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-glass-effect@57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3): + expo-glass-effect@57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-haptics@57.0.1(expo@57.0.11): + expo-haptics@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-json-utils@57.0.1: {} - expo-keep-awake@57.0.1(expo@57.0.11)(react@19.2.3): + expo-keep-awake@57.0.1(expo@57.0.12)(react@19.2.3): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - expo-linking@57.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): + expo-linking@57.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): dependencies: - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) + expo-constants: 57.0.9(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) invariant: 2.2.4 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -27898,17 +27890,17 @@ snapshots: - expo - supports-color - expo-location@57.0.8(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3): + expo-location@57.0.9(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript - expo-manifests@57.0.1(expo@57.0.11): + expo-manifests@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-json-utils: 57.0.1 expo-modules-autolinking@57.0.9(supports-color@11.0.0)(typescript@6.0.3): @@ -27945,26 +27937,26 @@ snapshots: dependencies: react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo-notifications@57.0.9(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): + expo-notifications@57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-application: 57.0.2(expo@57.0.11) - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-application: 57.0.2(expo@57.0.12) + expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - expo-router@57.0.11(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.8)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.11)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): + expo-router@57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): dependencies: - '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) - '@expo/metro-runtime': 57.0.8(@expo/log-box@57.0.2)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) + '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) + '@expo/metro-runtime': 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) '@expo/schema-utils': 57.0.2 - '@expo/ui': 57.0.9(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.11)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) + '@expo/ui': 57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.2)(react@19.2.3) @@ -27974,12 +27966,12 @@ snapshots: color: 4.2.3 debug: 4.4.3(supports-color@11.0.0) escape-string-regexp: 4.0.0 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) - expo-glass-effect: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) - expo-linking: 57.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) - expo-server: 57.0.1 - expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) + expo-glass-effect: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) + expo-linking: 57.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + expo-server: 57.0.2 + expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.17 @@ -28010,29 +28002,29 @@ snapshots: - react-native-worklets - supports-color - expo-secure-store@57.0.1(expo@57.0.11): + expo-secure-store@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-server@57.0.1: {} + expo-server@57.0.2: {} - expo-sharing@57.0.10(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): + expo-sharing@57.0.11(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-types': 57.0.2 '@expo/plist': 0.8.1(patch_hash=2e81ea41c32856973b677ca39a3f7f4a53b550dc5093fddfaa1abd120b800e2e) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - expo-splash-screen@57.0.5(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3): + expo-splash-screen@57.0.6(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3): dependencies: - '@expo/config-plugins': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/image-utils': 0.11.4(supports-color@11.0.0)(typescript@6.0.3) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -28040,26 +28032,26 @@ snapshots: expo-structured-headers@57.0.0: {} - expo-symbols@57.0.2(expo-font@57.0.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3): + expo-symbols@57.0.2(expo-font@57.0.1)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-font: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) sf-symbols-typescript: 2.2.0 - expo-task-manager@57.0.8(expo@57.0.11)(react-native@0.86.2): + expo-task-manager@57.0.9(expo@57.0.12)(react-native@0.86.2): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) unimodules-app-loader: 57.0.1 - expo-updates-interface@57.0.1(expo@57.0.11): + expo-updates-interface@57.0.1(expo@57.0.12): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-updates@57.0.12(expo-dev-client@57.0.10)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): + expo-updates@57.0.13(expo-dev-client@57.0.11)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.8.1(patch_hash=2e81ea41c32856973b677ca39a3f7f4a53b550dc5093fddfaa1abd120b800e2e) @@ -28067,11 +28059,11 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3(supports-color@11.0.0) - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-eas-client: 57.0.1 - expo-manifests: 57.0.1(expo@57.0.11) + expo-manifests: 57.0.1(expo@57.0.12) expo-structured-headers: 57.0.0 - expo-updates-interface: 57.0.1(expo@57.0.11) + expo-updates-interface: 57.0.1(expo@57.0.12) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 @@ -28080,34 +28072,34 @@ snapshots: react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 57.0.10(expo@57.0.11)(react-native@0.86.2) + expo-dev-client: 57.0.11(expo@57.0.12)(react-native@0.86.2) transitivePeerDependencies: - supports-color - expo-web-browser@57.0.2(expo@57.0.11)(react-native@0.86.2): + expo-web-browser@57.0.2(expo@57.0.12)(react-native@0.86.2): dependencies: - expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - expo@57.0.11(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): + expo@57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 57.0.13(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.8)(expo-constants@57.0.9)(expo-font@57.0.1)(expo-router@57.0.11)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - '@expo/config': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/cli': 57.0.14(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-router@57.0.12)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/config-plugins': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/devtools': 57.0.1(react-native@0.86.2)(react@19.2.3) - '@expo/fingerprint': 0.20.6(supports-color@11.0.0) - '@expo/local-build-cache-provider': 57.0.5(supports-color@11.0.0)(typescript@6.0.3) - '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@expo/fingerprint': 0.20.7(supports-color@11.0.0) + '@expo/local-build-cache-provider': 57.0.6(supports-color@11.0.0)(typescript@6.0.3) + '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@expo/metro': 56.0.0(supports-color@11.0.0) - '@expo/metro-config': 57.0.7(expo@57.0.11)(supports-color@11.0.0)(typescript@6.0.3) + '@expo/metro-config': 57.0.8(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 57.0.6(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.11)(react-refresh@0.14.2)(supports-color@11.0.0) - expo-asset: 57.0.9(expo@57.0.11)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - expo-constants: 57.0.9(expo@57.0.11)(react-native@0.86.2)(supports-color@11.0.0) - expo-file-system: 57.0.2(expo@57.0.11)(react-native@0.86.2) - expo-font: 57.0.1(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) - expo-keep-awake: 57.0.1(expo@57.0.11)(react@19.2.3) + babel-preset-expo: 57.0.6(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.12)(react-refresh@0.14.2)(supports-color@11.0.0) + expo-asset: 57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) + expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) + expo-file-system: 57.0.2(expo@57.0.12)(react-native@0.86.2) + expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) + expo-keep-awake: 57.0.1(expo@57.0.12)(react@19.2.3) expo-modules-autolinking: 57.0.9(supports-color@11.0.0)(typescript@6.0.3) expo-modules-core: 57.0.10(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) pretty-format: 29.7.0 @@ -28116,8 +28108,8 @@ snapshots: react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 55.0.5(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) - '@expo/metro-runtime': 57.0.8(@expo/log-box@57.0.2)(expo@57.0.11)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) + '@expo/dom-webview': 55.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) + '@expo/metro-runtime': 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) react-dom: 19.2.3(react@19.2.3) react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3) transitivePeerDependencies: @@ -31748,8 +31740,8 @@ snapshots: '@posthog/types': 1.399.0 optionalDependencies: '@react-native-async-storage/async-storage': 2.2.0(react-native@0.86.2) - expo-application: 57.0.2(expo@57.0.11) - expo-file-system: 57.0.2(expo@57.0.11)(react-native@0.86.2) + expo-application: 57.0.2(expo@57.0.12) + expo-file-system: 57.0.2(expo@57.0.12)(react-native@0.86.2) react-native-safe-area-context: 5.7.0(react-native@0.86.2)(react@19.2.3) react-native-svg: 15.15.4(react-native@0.86.2)(react@19.2.3) @@ -32049,7 +32041,7 @@ snapshots: react-native-modal-datetime-picker@18.0.0(@react-native-community/datetimepicker@9.1.0)(react-native@0.86.2): dependencies: - '@react-native-community/datetimepicker': 9.1.0(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) + '@react-native-community/datetimepicker': 9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) prop-types: 15.8.1 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 63db18b76c..597d966322 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -103,28 +103,28 @@ minimumReleaseAgeExclude: - '@ai-sdk/otel@1.0.47' - '@ai-sdk/provider-utils@5.0.18' - ai@7.0.47 - - '@expo/cli@57.0.11 || 57.0.12 || 57.0.13' - - '@expo/inline-modules@0.1.4' - - '@expo/local-build-cache-provider@57.0.5' + - '@expo/cli@57.0.11 || 57.0.12 || 57.0.13 || 57.0.14' + - '@expo/inline-modules@0.1.4 || 0.1.5' + - '@expo/local-build-cache-provider@57.0.5 || 57.0.6' - '@expo/log-box@57.0.2' - - '@expo/metro-runtime@57.0.8' - - '@expo/prebuild-config@57.0.10' - - '@expo/ui@57.0.8 || 57.0.9' + - '@expo/metro-runtime@57.0.8 || 57.0.9' + - '@expo/prebuild-config@57.0.10 || 57.0.11' + - '@expo/ui@57.0.8 || 57.0.9 || 57.0.10' - babel-preset-expo@57.0.5 || 57.0.6 - - expo@57.0.9 || 57.0.10 || 57.0.11 - - expo-asset@57.0.8 || 57.0.9 - - expo-build-properties@57.0.8 || 57.0.9 - - expo-constants@57.0.8 || 57.0.9 - - expo-dev-client@57.0.10 - - expo-dev-launcher@57.0.10 - - expo-dev-menu@57.0.10 - - expo-location@57.0.7 || 57.0.8 + - expo@57.0.9 || 57.0.10 || 57.0.11 || 57.0.12 + - expo-asset@57.0.8 || 57.0.9 || 57.0.10 + - expo-build-properties@57.0.8 || 57.0.9 || 57.0.10 + - expo-constants@57.0.8 || 57.0.9 || 57.0.10 + - expo-dev-client@57.0.10 || 57.0.11 + - expo-dev-launcher@57.0.10 || 57.0.11 + - expo-dev-menu@57.0.10 || 57.0.11 + - expo-location@57.0.7 || 57.0.8 || 57.0.9 - expo-modules-core@57.0.8 || 57.0.9 || 57.0.10 - - expo-notifications@57.0.8 || 57.0.9 - - expo-router@57.0.9 || 57.0.10 || 57.0.11 - - expo-sharing@57.0.8 || 57.0.10 - - expo-task-manager@57.0.7 || 57.0.8 - - expo-updates@57.0.11 || 57.0.12 + - expo-notifications@57.0.8 || 57.0.9 || 57.0.10 + - expo-router@57.0.9 || 57.0.10 || 57.0.11 || 57.0.12 + - expo-sharing@57.0.8 || 57.0.10 || 57.0.11 + - expo-task-manager@57.0.7 || 57.0.8 || 57.0.9 + - expo-updates@57.0.11 || 57.0.12 || 57.0.13 - '@posthog/core@1.46.1' - posthog-node@5.47.3 - posthog-react-native@4.61.2 @@ -133,3 +133,7 @@ minimumReleaseAgeExclude: - '@expo/config-plugins@57.0.7' - expo-file-system@57.0.2 - expo-symbols@57.0.2 + - '@expo/fingerprint@0.20.7' + - '@expo/metro-config@57.0.8' + - expo-server@57.0.2 + - expo-splash-screen@57.0.6 From 101c4a86a817319b517157baf3f33d5bd86e5f50 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 11:10:28 -0700 Subject: [PATCH 27/46] Add body fat percentage trend charts to web and mobile (#2470) * docs: specify body fat percentage chart * docs: plan body fat percentage chart * feat: expose mobile body fat history * feat: add web body fat chart * feat: show body fat chart on web body page * feat: show body fat trend on mobile recovery * test: cover mobile recovery body fat response * test: type recovery body fat fixture * fix: close body fat chart review findings * fix: preserve body fat chart layout * fix: restore CI checks * test: strengthen processing mutations --- .../plans/2026-08-10-body-fat-chart.md | 359 ++++++++++++++++++ .../specs/2026-08-10-body-fat-chart-design.md | 67 ++++ .../mobile/app-tests/(tabs)/index.test.tsx | 9 +- .../mobile/app-tests/(tabs)/recovery.test.tsx | 53 ++- packages/mobile/app/(tabs)/recovery.tsx | 38 ++ .../mobile-dashboard-contracts.test.ts | 8 + .../contracts/mobile-dashboard-contracts.ts | 6 + .../processing-repository.test.ts | 178 +++++++++ .../src/routers/mobile-dashboard.test.ts | 4 + .../src/services/mobile-recovery-tab.test.ts | 39 ++ .../src/services/mobile-recovery-tab.ts | 9 +- .../BodyFatPercentageChart.stories.tsx | 58 +++ .../BodyFatPercentageChart.test.tsx | 181 +++++++++ .../src/components/BodyFatPercentageChart.tsx | 73 ++++ .../src/components/DataSourcesPanel.test.tsx | 1 + packages/web/src/pages/BodyPage.test.tsx | 12 + packages/web/src/pages/BodyPage.tsx | 12 +- 17 files changed, 1099 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-body-fat-chart.md create mode 100644 docs/superpowers/specs/2026-08-10-body-fat-chart-design.md create mode 100644 packages/web/src/components/BodyFatPercentageChart.stories.tsx create mode 100644 packages/web/src/components/BodyFatPercentageChart.test.tsx create mode 100644 packages/web/src/components/BodyFatPercentageChart.tsx diff --git a/docs/superpowers/plans/2026-08-10-body-fat-chart.md b/docs/superpowers/plans/2026-08-10-body-fat-chart.md new file mode 100644 index 0000000000..2d772acae7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-body-fat-chart.md @@ -0,0 +1,359 @@ +# Body Fat Percentage Chart Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a standalone body-fat-percentage trend chart to the web Body page and the equivalent mobile Recovery/body-composition experience. + +**Architecture:** Reuse the existing server-owned body-composition rows. The web chart reads `bodyAnalytics.weightOverview.recomposition`; the mobile recovery service adds a small `bodyFat` projection from `BodyAnalyticsRepository.getRecomposition(days, endDate)`, and the mobile UI renders that projection with the existing `SparkLine`. No client computes, smooths, or aggregates body-fat values. + +**Tech Stack:** TypeScript, React, React Native, tRPC/Zod, ECharts through `DofekChart`, `react-native-svg` through `SparkLine`, Vitest, Testing Library. + +## Global Constraints + +- Implement the feature on both `packages/web` and `packages/mobile`. +- Metric values remain server-computed; clients only render server-provided values. +- Keep body-fat data provider-agnostic and reuse the existing canonical body-composition source. +- Treat loading, error, and insufficient-data states explicitly. +- Write a failing test before each production behavior change and verify the failure before implementation. +- Keep mobile route files route-only; place tests outside `packages/mobile/app/`. +- Use shared unit/body-composition formatters; never hardcode a weight unit. +- Do not add database tables, columns, provider-specific storage, or duplicate body-composition sources. + +--- + +### Task 1: Expose body-fat history in the mobile Recovery contract + +**Files:** +- Modify: `packages/server/src/contracts/mobile-dashboard-contracts.ts:229-239` to add the `bodyFat` output array. +- Modify: `packages/server/src/services/mobile-recovery-tab.ts:190-270` to load and return the selected-range body-fat rows. +- Test: `packages/server/src/services/mobile-recovery-tab.test.ts` for selected-range body-fat output. +- Test: `packages/server/src/contracts/mobile-dashboard-contracts.test.ts` for contract parsing of the new field. + +**Interfaces:** +- Consumes: `BodyAnalyticsRepository.getRecomposition(days: RangeDays, endDate: string): Promise`. +- Produces: `MobileRecoveryTabResult.bodyFat: Array<{ date: string; bodyFatPct: number }>`. + +- [ ] **Step 1: Write the failing service test** + +Add a fixture with two body-composition rows returned by the repository and assert that `loadMobileRecoveryTab(ctx, 30, "2026-03-28")` returns: + +```ts +expect(result.bodyFat).toEqual([ + { date: "2026-03-10", bodyFatPct: 21.4 }, + { date: "2026-03-20", bodyFatPct: 20.9 }, +]); +``` + +Also assert that the repository is called with the selected range, `30`, rather than the weight trend's minimum 90-day window. Use the existing repository spies/helpers in `mobile-recovery-tab.test.ts` so the test observes the real service contract rather than mocking the client. + +- [ ] **Step 2: Run the service test and verify it fails for the missing field** + +Run: + +```bash +pnpm exec vitest run packages/server/src/services/mobile-recovery-tab.test.ts -t "body-fat" +``` + +Expected: FAIL because the recovery result does not yet expose `bodyFat` or call `getRecomposition` for the selected range. + +- [ ] **Step 3: Write the failing contract test** + +Extend the existing valid mobile recovery fixture with: + +```ts +bodyFat: [{ date: "2026-03-20", bodyFatPct: 20.9 }] +``` + +Parse it through `mobileRecoveryTabOutputSchema` and assert the field survives parsing with the exact date and percentage value. + +- [ ] **Step 4: Run the contract test and verify it fails** + +Run: + +```bash +pnpm exec vitest run packages/server/src/contracts/mobile-dashboard-contracts.test.ts -t "bodyFat" +``` + +Expected: FAIL because the schema does not yet define the field. + +- [ ] **Step 5: Add the minimal Zod output field** + +Add this field next to `weight` in `mobileRecoveryTabOutputSchema`: + +```ts +bodyFat: z.array( + z.object({ + date: dateSchema, + bodyFatPct: z.number(), + }), +), +``` + +Do not add a second source of body-fat data or any derived aggregate. + +- [ ] **Step 6: Load the canonical rows in the recovery service** + +Add `bodyRepo.getRecomposition(days, endDate)` to the service's existing body analytics `Promise.all`, then return only the public projection: + +```ts +bodyFat: bodyFat.map(({ date, bodyFatPct }) => ({ date, bodyFatPct })), +``` + +Keep the existing `weightDays = Math.max(days, 90)` behavior for trend weight and use the requested `days` specifically for the body-fat series. + +- [ ] **Step 7: Run the focused server tests** + +Run: + +```bash +pnpm exec vitest run packages/server/src/services/mobile-recovery-tab.test.ts packages/server/src/contracts/mobile-dashboard-contracts.test.ts +``` + +Expected: PASS with no unrelated test failures. + +- [ ] **Step 8: Commit the server contract change** + +```bash +git add packages/server/src/contracts/mobile-dashboard-contracts.ts packages/server/src/contracts/mobile-dashboard-contracts.test.ts packages/server/src/services/mobile-recovery-tab.ts packages/server/src/services/mobile-recovery-tab.test.ts +git commit -m "feat: expose mobile body fat history" +``` + +### Task 2: Build the web body-fat chart component + +**Files:** +- Create: `packages/web/src/components/BodyFatPercentageChart.tsx`. +- Create: `packages/web/src/components/BodyFatPercentageChart.test.tsx`. +- Create: `packages/web/src/components/BodyFatPercentageChart.stories.tsx`. + +**Interfaces:** +- Consumes: `BodyRecompositionRow[]` from `packages/server/src/routers/body-analytics.ts`, plus optional `loading`. +- Produces: A standalone ECharts chart with a `Body Fat %` series and explicit insufficient-data behavior. + +- [ ] **Step 1: Write the failing chart tests** + +Create a jsdom test using the same `echarts-for-react` capture pattern as `BodyRecompositionChart.test.tsx`. Cover these behaviors: + +1. With two rows, the chart renders and the captured option contains one line series named `Body Fat %` with values `[date, bodyFatPct]`. +2. The captured y-axis is labeled `%`. +3. The tooltip formats a value such as `20.9` as `20.9%`, includes the formatted date, and does not expose raw floating-point noise. +4. With fewer than two rows, no chart is rendered and the user sees an explicit message that at least two body-fat readings are needed. + +Use the existing `UnitContext` and `DofekChart` mocking conventions; do not test ECharts internals. + +- [ ] **Step 2: Run the chart tests and verify they fail** + +Run: + +```bash +pnpm exec vitest run packages/web/src/components/BodyFatPercentageChart.test.tsx +``` + +Expected: FAIL because the component does not exist. + +- [ ] **Step 3: Implement the minimal chart component** + +Use the existing chart helpers from `chartTheme.ts` and the existing `DofekChart` component. The core option should follow this shape: + +```ts +series: [ + dofekSeries.line( + "Body Fat %", + data.map((row) => [row.date, row.bodyFatPct] as [string, number]), + { color: chartColors.purple }, + ), +], +yAxis: dofekAxis.value({ name: "%" }), +``` + +Use `formatBodyCompositionNumber` for tooltip values, `formatDateShort` for dates, and `escapeTooltipHtml` for dynamic tooltip content. Use a single-series grid and the standard Dofek tooltip/axis/legend helpers. Return the standard chart empty state for fewer than two rows and pass through `loading`. + +- [ ] **Step 4: Run the chart tests and verify they pass** + +Run: + +```bash +pnpm exec vitest run packages/web/src/components/BodyFatPercentageChart.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Add the Storybook coverage** + +Create a story with generated dated rows covering normal, loading, and empty states. Use the existing `BodyRecompositionChart.stories.tsx` data-generation and `UnitContext` patterns. Keep the story focused on chart rendering; do not add new page-level fixtures. + +- [ ] **Step 6: Commit the web chart component** + +```bash +git add packages/web/src/components/BodyFatPercentageChart.tsx packages/web/src/components/BodyFatPercentageChart.test.tsx packages/web/src/components/BodyFatPercentageChart.stories.tsx +git commit -m "feat: add web body fat chart" +``` + +### Task 3: Add the chart to the web Body page + +**Files:** +- Modify: `packages/web/src/pages/BodyPage.tsx:11-18,325-352` to import and render the new chart. +- Modify: `packages/web/src/pages/BodyPage.test.tsx:10-25,105-155` to cover page wiring. + +**Interfaces:** +- Consumes: `BodyFatPercentageChart` and `weightOverview.data.recomposition`. +- Produces: A Body Composition grid with Trend Weight, Recomposition, and Body Fat Percentage cards. + +- [ ] **Step 1: Write the failing page integration test** + +Mock `BodyFatPercentageChart` alongside the existing Body page chart mocks, returning a visible marker such as `Body fat points: {data.length}`. Render the existing healthy overview fixture and assert the Body page includes that marker with the number of recomposition rows. This test must fail before the page renders the new component. + +- [ ] **Step 2: Run the page test and verify it fails** + +Run: + +```bash +pnpm exec vitest run packages/web/src/pages/BodyPage.test.tsx -t "body fat" +``` + +Expected: FAIL because the page has no Body Fat Percentage chart. + +- [ ] **Step 3: Wire the chart into the existing Body Composition grid** + +Import `BodyFatPercentageChart` and add a third card titled `Body Fat Percentage` inside the existing `!weightOverviewUnavailable` grid. Pass `weightOverview.data?.recomposition ?? []` and `loading={weightOverview.isLoading}`. Keep the card in the same error/loading boundary as the existing body-composition charts so the established page-level dependency notice remains the single retry surface. + +- [ ] **Step 4: Run the page tests and web component tests** + +Run: + +```bash +pnpm exec vitest run packages/web/src/pages/BodyPage.test.tsx packages/web/src/components/BodyFatPercentageChart.test.tsx packages/web/src/components/BodyRecompositionChart.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the web page integration** + +```bash +git add packages/web/src/pages/BodyPage.tsx packages/web/src/pages/BodyPage.test.tsx +git commit -m "feat: show body fat chart on web body page" +``` + +### Task 4: Render body-fat history on mobile Recovery + +**Files:** +- Modify: `packages/mobile/app/(tabs)/recovery.tsx:230-245,640-690` to derive display-only references and render the card. +- Modify: `packages/mobile/app-tests/(tabs)/recovery.test.tsx` to cover body-fat rendering and SparkLine input. + +**Interfaces:** +- Consumes: `recoveryData.bodyFat: Array<{ date: string; bodyFatPct: number }>` from Task 1. +- Produces: A `Body Fat %` card near Trend Weight, with the latest formatted percentage and a SparkLine containing the server-provided percentages. + +- [ ] **Step 1: Write the failing mobile UI test** + +Add a recovery fixture with two body-fat rows and assert: + +```ts +expect(screen.getByText("BODY FAT %")).toBeTruthy(); +expect(screen.getByText("20.9%")).toBeTruthy(); +expect(sparkLinePropsCalls.some((props) => props.data?.join(",") === "21.4,20.9")).toBe(true); +``` + +Use the existing mocked `SparkLine` call capture. The test should verify the UI consumes the response values directly and does not need to know how they were calculated. + +- [ ] **Step 2: Run the mobile test and verify it fails** + +Run: + +```bash +pnpm exec vitest run packages/mobile/app-tests/'(tabs)'/recovery.test.tsx -t "body fat" +``` + +Expected: FAIL because the recovery screen does not yet read `bodyFat` or render the card. + +- [ ] **Step 3: Add display-only body-fat references** + +Near the existing `weightData` references, add: + +```ts +const bodyFatData = recoveryData?.bodyFat ?? []; +const latestBodyFat = bodyFatData.at(-1)?.bodyFatPct ?? null; +``` + +Do not calculate a trend, average, delta, or smoothed value on the client. + +- [ ] **Step 4: Render the mobile body-fat card** + +Place the new `Card` immediately after the Trend Weight card. Render it only when `latestBodyFat != null`; show the latest value with `${formatBodyCompositionNumber(latestBodyFat)}%` and render a `SparkLine` when at least two values exist: + +```tsx + row.bodyFatPct)} + height={50} + color={colors.purple} + showYAxis + formatYLabel={(value) => `${formatBodyCompositionNumber(value)}%`} +/> +``` + +Use the existing card styles and keep the card free of client-derived summary text. + +- [ ] **Step 5: Run the mobile tests** + +Run: + +```bash +pnpm exec vitest run packages/mobile/app-tests/'(tabs)'/recovery.test.tsx packages/server/src/services/mobile-recovery-tab.test.ts packages/server/src/contracts/mobile-dashboard-contracts.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the mobile UI integration** + +```bash +git add 'packages/mobile/app/(tabs)/recovery.tsx' 'packages/mobile/app-tests/(tabs)/recovery.test.tsx' +git commit -m "feat: show body fat trend on mobile recovery" +``` + +### Task 5: Verify the complete feature + +**Files:** +- Modify: only files identified by failing checks, if a focused correction is required. +- Test: the web and mobile/server suites listed below. + +- [ ] **Step 1: Run all focused feature tests** + +```bash +pnpm exec vitest run \ + packages/server/src/services/mobile-recovery-tab.test.ts \ + packages/server/src/contracts/mobile-dashboard-contracts.test.ts \ + packages/web/src/components/BodyFatPercentageChart.test.tsx \ + packages/web/src/pages/BodyPage.test.tsx \ + packages/mobile/app-tests/'(tabs)'/recovery.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 2: Run typecheck and lint for the touched packages** + +```bash +pnpm typecheck +pnpm lint +``` + +Expected: PASS without changing thresholds, suppressing rules, or adding ignores. + +- [ ] **Step 3: Review the final diff and repository status** + +```bash +git diff --check HEAD~5..HEAD +git status --short +``` + +Confirm the only untracked file still present is any pre-existing user file (currently `paseo.json`), and that no generated route files or files under `packages/mobile/app/` outside the route itself were added. + +- [ ] **Step 4: Run the complete relevant test tier if focused checks pass** + +```bash +pnpm test:changed +``` + +If the changed-test tier requires database-backed dependencies, use the repository's documented `pnpm test:changed:all` tier instead of invoking raw Compose commands. + +- [ ] **Step 5: Commit any final correction and report validation** + +If the final checks require a code correction, add a focused regression test first, rerun the failing command, then commit with a message describing the correction. Otherwise, report the focused tests, lint, typecheck, and changed-test results, along with the final commit IDs. diff --git a/docs/superpowers/specs/2026-08-10-body-fat-chart-design.md b/docs/superpowers/specs/2026-08-10-body-fat-chart-design.md new file mode 100644 index 0000000000..9f39007ff6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-body-fat-chart-design.md @@ -0,0 +1,67 @@ +# Body Fat Percentage Chart + +## Goal + +Add a standalone body-fat-percentage trend chart to the Body experience on web +and the equivalent Recovery/body-composition area on mobile. + +## Scope + +- Reuse the existing server-owned body-composition measurements and the user's + selected body time range. +- Add a web chart beside Trend Weight and Recomposition. +- Extend the mobile Recovery response with dated body-fat readings and render a + compact percentage trend near Trend Weight. +- Preserve explicit loading, error, and insufficient-data states. +- Add focused unit/component tests for the chart, page integration, mobile + rendering, and the mobile response contract/service. + +## Data flow + +The web chart will consume `bodyAnalytics.weightOverview.recomposition`, whose +rows already contain `date` and `bodyFatPct`. The chart will render those values +as percentages without deriving or aggregating them in client code. + +The mobile recovery service will load the existing repository recomposition +rows for the selected range and expose only the dated `bodyFatPct` values in a +new response field. The mobile client will render that server-provided series +with the existing SVG-based `SparkLine` component. + +The mobile body-fat query will use the same selected range as the Recovery +query. Existing weight data, body decision context, and weight prediction stay +unchanged. + +## UI behavior + +### Web + +- Section title: `Body Fat Percentage`. +- Chart y-axis label: `%`. +- Tooltip values use the shared body-composition formatter and show the date. +- Fewer than two usable readings shows an explicit insufficient-data message. +- Loading and existing query error behavior use the page's established chart + and query-state components. + +### Mobile + +- Add a `Body Fat %` card near `Trend Weight`. +- Show the latest value and a compact percentage SparkLine when readings exist. +- Do not show a misleading zero/empty chart when there are no readings. +- Format values with the shared body-composition formatter. + +## Testing + +- Web chart tests verify rendered series values, percentage tooltip formatting, + and insufficient-data behavior. +- Web BodyPage tests verify the new chart is wired to the existing overview + response. +- Mobile contract/service tests verify the new response field is populated + from repository recomposition data and respects the selected date range. +- Mobile Recovery tests verify the card and SparkLine receive body-fat values. + +## Non-goals + +- No new database tables, columns, or provider-specific storage. +- No client-side smoothing, averaging, or body-fat calculations. +- No replacement of the existing Recomposition chart. +- No changes to the selected time-range controls or body analytics semantics. diff --git a/packages/mobile/app-tests/(tabs)/index.test.tsx b/packages/mobile/app-tests/(tabs)/index.test.tsx index b390e635fb..3ab69574f5 100644 --- a/packages/mobile/app-tests/(tabs)/index.test.tsx +++ b/packages/mobile/app-tests/(tabs)/index.test.tsx @@ -105,7 +105,6 @@ vi.mock("../../lib/trpc", () => ({ }; }, }, - dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, triggerSync: { useMutation: () => ({ mutate: vi.fn(), @@ -113,8 +112,14 @@ vi.mock("../../lib/trpc", () => ({ }), }, activeSyncs: { useQuery: () => ({ data: [], isLoading: false }) }, + dismiss: { + useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }), + }, }, - useUtils: () => ({ invalidate: mockInvalidate }), + useUtils: () => ({ + invalidate: mockInvalidate, + processing: { status: { invalidate: vi.fn() } }, + }), }, })); diff --git a/packages/mobile/app-tests/(tabs)/recovery.test.tsx b/packages/mobile/app-tests/(tabs)/recovery.test.tsx index 8a482ee15e..2f6ecfd39f 100644 --- a/packages/mobile/app-tests/(tabs)/recovery.test.tsx +++ b/packages/mobile/app-tests/(tabs)/recovery.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; +import type { MobileRecoveryTabResult } from "dofek-server/mobile-dashboard-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; let mockRecoveryData: Record | undefined; @@ -89,7 +90,35 @@ const insufficientHealthspan = { summary: "0 of 3 required Healthspan metrics are available.", nextCondition: "The score becomes available after 3 more supported metrics sync successfully.", }, -} as const; +} satisfies MobileRecoveryTabResult["healthspan"]; + +function createRecoveryFixture( + overrides: Partial = {}, +): MobileRecoveryTabResult { + return { + hrvVariability: [], + hrvBaseline: [], + readinessScore: [], + stress: { daily: [], weekly: [], latestScore: null, trend: "stable" }, + trends: null, + dailyMetrics: [], + weight: [], + bodyFat: [], + decisionContext: null, + weightPrediction: { + ratePerWeek: null, + rateConfidence: null, + impliedDailyCalories: null, + periodDeltas: { days7: null, days14: null, days30: null }, + goal: null, + projectionLine: [], + }, + baselineRelative: [], + healthStatus: [], + healthspan: insufficientHealthspan, + ...overrides, + }; +} vi.mock("../../lib/trpc", () => ({ trpc: { @@ -326,6 +355,28 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { expect(mockTodayPlanQueryCalls).toEqual([{ days: 30, endDate: "2026-07-26" }]); }); + it("renders the server-authored body fat history", async () => { + mockRecoveryData = createRecoveryFixture({ + bodyFat: [ + { date: "2026-03-10", bodyFatPct: 21.4 }, + { date: "2026-03-20", bodyFatPct: 20.9 }, + ], + }); + + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); + render(); + + expect(screen.getByText("BODY FAT %")).toBeTruthy(); + expect(screen.getByText("20.9%")).toBeTruthy(); + expect(sparkLinePropsCalls.some((props) => props.data?.join(",") === "21.4,20.9")).toBe(true); + const bodyFatTrend = screen.getByLabelText( + "Body fat trend: 2026-03-10 21.4%; 2026-03-20 20.9%.", + ); + expect(bodyFatTrend).toBeTruthy(); + expect(bodyFatTrend.style.flex).toBe("1 1 0%"); + expect(bodyFatTrend.style.marginLeft).toBe("16px"); + }); + it("does not consume cached default-range data during preference hydration", async () => { mockRecoveryData = { readinessScore: [{ date: "2026-04-06", readinessScore: 77 }], diff --git a/packages/mobile/app/(tabs)/recovery.tsx b/packages/mobile/app/(tabs)/recovery.tsx index cb62766136..88af46fde4 100644 --- a/packages/mobile/app/(tabs)/recovery.tsx +++ b/packages/mobile/app/(tabs)/recovery.tsx @@ -237,6 +237,8 @@ export default function RecoveryScreen() { const weightData = recoveryData?.weight ?? []; const latestWeight = weightData.length > 0 ? weightData[weightData.length - 1] : null; + const bodyFatData = recoveryData?.bodyFat ?? []; + const latestBodyFat = bodyFatData.at(-1)?.bodyFatPct ?? null; const weightPrediction = recoveryData?.weightPrediction; const healthspan = recoveryData?.healthspan; @@ -674,6 +676,42 @@ export default function RecoveryScreen() { )} + {latestBodyFat != null && ( + + + + {formatBodyCompositionNumber(latestBodyFat)}% + + {bodyFatData.length >= 2 && ( + + `${date} ${formatBodyCompositionNumber(bodyFatPct)}%`, + ) + .join("; ")}.`} + style={styles.sparkContainer} + > + + row.bodyFatPct)} + height={50} + color={colors.purple} + showYAxis + formatYLabel={(value) => `${formatBodyCompositionNumber(value)}%`} + /> + + + )} + + + )} + {/* Daily Steps */} {latestSteps != null && ( diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index 0075614754..d94f952d83 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -7,6 +7,7 @@ import { type MobileRecoveryTabResult, type MobileTrainingTabResult, mobileRecoveryFixtureSchema, + mobileRecoveryTabOutputSchema, mobileTrainingFixtureSchema, type WorkloadRatioResult, workloadDisplayFixtureSchema, @@ -127,6 +128,7 @@ function validRecoveryFixture(): z.input { interpolated: false, }, ], + bodyFat: [{ date: "2026-03-20", bodyFatPct: 20.9 }], decisionContext: { latestMeasurement: { date: input.endDate, @@ -339,6 +341,12 @@ describe("mobileRecoveryFixtureSchema", () => { expect(mobileRecoveryFixtureSchema.parse(validRecoveryFixture())).toBeTruthy(); }); + it("parses bodyFat history", () => { + const parsed = mobileRecoveryTabOutputSchema.parse(validRecoveryFixture().data); + + expect(parsed.bodyFat).toEqual([{ date: "2026-03-20", bodyFatPct: 20.9 }]); + }); + it("rejects stress values outside the server-owned 0-3 range", () => { const fixture = validRecoveryFixture(); const latestStress = fixture.data.stress.daily[1]; diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index 068560c7f6..b47faa5bd2 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -237,6 +237,12 @@ export const mobileRecoveryTabOutputSchema = z.object({ interpolated: z.boolean(), }), ), + bodyFat: z.array( + z.object({ + date: dateSchema, + bodyFatPct: z.number(), + }), + ), decisionContext: bodyDecisionContextOutputSchema.nullable(), weightPrediction: z.object({ ratePerWeek: z.number().nullable(), diff --git a/packages/server/src/repositories/processing-repository.test.ts b/packages/server/src/repositories/processing-repository.test.ts index 3aac920610..62a4599a17 100644 --- a/packages/server/src/repositories/processing-repository.test.ts +++ b/packages/server/src/repositories/processing-repository.test.ts @@ -419,6 +419,99 @@ describe("ProcessingRepository", () => { expect(result.operations[0]?.errorMessage).toBe("Activity analytics failed."); }); + it("selects the newest failed event for the requested dataset", async () => { + const newestActivityFailure = new Date("2026-07-22T17:45:00.000Z"); + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + datasetKeys: ["activity", "sleep"], + outputManifest: { activity: ["relational"], sleep: ["relational"] }, + events: [ + event(1, { + stage: "analytics", + status: "failed", + datasetKey: "sleep", + occurredAt: new Date("2026-07-22T17:55:00.000Z"), + errorMessage: "Sleep analytics failed.", + }), + event(2, { + stage: "ingest", + status: "failed", + datasetKey: "activity", + occurredAt: new Date("2026-07-22T17:10:00.000Z"), + errorMessage: "Old activity failure.", + }), + event(3, { + stage: "analytics", + status: "failed", + datasetKey: "activity", + occurredAt: newestActivityFailure, + errorMessage: "Newest activity failure.", + }), + ], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const status = await repository.status({ datasets: ["activity"] }); + const alerts = await repository.alerts(); + + expect(status.datasets[0]?.lastFailedAt).toBe(newestActivityFailure.toISOString()); + expect(status.operations[0]?.errorMessage).toBe("Newest activity failure."); + expect(alerts.alerts[0]?.occurredAt).toBe(newestActivityFailure.toISOString()); + }); + + it("uses event sequence to break ties between same-time failures", async () => { + const failedAt = new Date("2026-07-22T17:45:00.000Z"); + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + events: [ + event(1, { + stage: "analytics", + status: "failed", + occurredAt: failedAt, + errorMessage: "Earlier failure at the same time.", + }), + event(2, { + stage: "analytics", + status: "failed", + occurredAt: failedAt, + errorMessage: "Later failure at the same time.", + }), + ], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const status = await repository.status({ datasets: ["activity"] }); + + expect(status.datasets[0]?.lastFailedAt).toBe(failedAt.toISOString()); + expect(status.operations[0]?.errorMessage).toBe("Later failure at the same time."); + }); + it("keeps a dataset ready when a later operation succeeds and suppresses the old alert", async () => { const olderFailure = operation({ id: "10000000-0000-4000-8000-000000000041", @@ -929,6 +1022,41 @@ describe("ProcessingRepository", () => { }); }); + it("uses the first available dataset timestamp when no failure event is available", async () => { + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + providerId: null, + datasetKeys: ["activity", "recovery"], + outputManifest: { activity: ["relational"], recovery: ["relational"] }, + events: [event(1, { stage: "ingest", status: "succeeded" })], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "ingest", + status: "failed", + progressPercentage: null, + lastAdvancedAt: null, + }, + { + datasetKey: "recovery", + currentStage: "ingest", + status: "failed", + progressPercentage: null, + lastAdvancedAt: new Date("2026-07-22T17:30:00.000Z"), + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const alerts = await repository.alerts(); + + expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:30:00.000Z"); + }); + it("falls back to support guidance for a failed recomputation", async () => { mockListScopedProcessingOperations.mockResolvedValue([ operation({ @@ -1095,6 +1223,56 @@ describe("ProcessingRepository", () => { expect(alerts.alerts[0]?.datasetKeys).toEqual(["activity", "recovery", "sleep"]); expect(alerts.alerts[0]?.datasetLabels).toEqual(["Activities", "Recovery", "Sleep"]); expect(alerts.alerts[0]?.occurredAt).toBe("2026-07-22T17:50:00.000Z"); + expect(alerts.alerts[0]).toMatchObject({ + title: "Garmin activities, recovery, and sleep weren’t updated", + message: + "Your Garmin data synced, but Dofek couldn’t update activities, recovery, and sleep. Your previously synced data is still available.", + action: "retry_sync", + actionLabel: "Retry Garmin sync", + }); + }); + + it("formats a two-dataset provider alert without an Oxford comma", async () => { + mockListScopedProcessingOperations.mockResolvedValue([ + operation({ + providerId: "garmin", + kind: "provider_sync", + datasetKeys: ["activity", "recovery"], + outputManifest: { activity: ["relational"], recovery: ["relational"] }, + events: [ + event(1, { stage: "analytics", status: "failed", datasetKey: "activity" }), + event(2, { stage: "analytics", status: "failed", datasetKey: "recovery" }), + ], + }), + ]); + mockDeriveProcessingState.mockReturnValue({ + overallStatus: "failed", + datasets: [ + { + datasetKey: "activity", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + { + datasetKey: "recovery", + currentStage: "analytics", + status: "failed", + progressPercentage: null, + lastAdvancedAt: now, + }, + ], + }); + const repository = new ProcessingRepository(database, userId); + + const alerts = await repository.alerts(); + + expect(alerts.alerts[0]).toMatchObject({ + title: "Garmin activities and recovery weren’t updated", + message: + "Your Garmin data synced, but Dofek couldn’t update activities and recovery. Your previously synced data is still available.", + }); }); it("marks dismissed operations in status and omits them from alerts", async () => { diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 6c463f8a62..3ab99205c4 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -854,6 +854,7 @@ function emptyRecoveryTabResult(): import("../services/mobile-recovery-tab.ts"). dailyMetrics: [], baselineRelative: [], weight: [], + bodyFat: [], decisionContext: null, weightPrediction: { ratePerWeek: null, @@ -1021,6 +1022,7 @@ describe("mobileDashboard.recovery", () => { expect(result.readinessScore).toHaveLength(1); expect(result.stress.daily).toHaveLength(1); + expect(result.bodyFat).toEqual([]); const timingCall = vi .mocked(logger.info) .mock.calls.find((call) => String(call[0]).includes("[mobile-dashboard] recovery timings")); @@ -1038,6 +1040,7 @@ describe("mobileDashboard.recovery", () => { dailyMetrics: [], baselineRelative: [], weight: [], + bodyFat: [], decisionContext: null, weightPrediction: { ratePerWeek: null, @@ -1113,6 +1116,7 @@ describe("mobileDashboard.recovery", () => { dailyMetrics: [], baselineRelative: [], weight: [], + bodyFat: [], decisionContext: null, weightPrediction: { ratePerWeek: null, diff --git a/packages/server/src/services/mobile-recovery-tab.test.ts b/packages/server/src/services/mobile-recovery-tab.test.ts index 5047310366..17cdc3c7a8 100644 --- a/packages/server/src/services/mobile-recovery-tab.test.ts +++ b/packages/server/src/services/mobile-recovery-tab.test.ts @@ -39,6 +39,45 @@ vi.mock("./health-status.ts", async (importOriginal) => { }); describe("loadMobileRecoveryTab", () => { + it("returns body-fat history for the selected range", async () => { + const getRecomposition = vi + .spyOn( + (await import("../repositories/body-analytics-repository.ts")).BodyAnalyticsRepository + .prototype, + "getRecomposition", + ) + .mockResolvedValue([ + { + date: "2026-03-10", + weightKg: 80, + bodyFatPct: 21.4, + fatMassKg: 17.12, + leanMassKg: 62.88, + smoothedFatMass: 17.12, + smoothedLeanMass: 62.88, + }, + { + date: "2026-03-20", + weightKg: 79, + bodyFatPct: 20.9, + fatMassKg: 16.511, + leanMassKg: 62.489, + smoothedFatMass: 16.511, + smoothedLeanMass: 62.489, + }, + ]); + + const result = await runRecoveryTab(loadMobileRecoveryTab, []); + + expect(result.bodyFat).toEqual([ + { date: "2026-03-10", bodyFatPct: 21.4 }, + { date: "2026-03-20", bodyFatPct: 20.9 }, + ]); + expect(getRecomposition).toHaveBeenCalledWith(30, "2026-03-28"); + + getRecomposition.mockRestore(); + }); + it("returns server-authored body decision context alongside recovery data", async () => { const decisionContext: BodyDecisionContext = { latestMeasurement: { diff --git a/packages/server/src/services/mobile-recovery-tab.ts b/packages/server/src/services/mobile-recovery-tab.ts index 4c8fdc8537..2856e22400 100644 --- a/packages/server/src/services/mobile-recovery-tab.ts +++ b/packages/server/src/services/mobile-recovery-tab.ts @@ -251,10 +251,11 @@ export async function loadMobileRecoveryTab( const goalWeightKg = parsedGoalWeightKg != null && Number.isFinite(parsedGoalWeightKg) ? parsedGoalWeightKg : null; - const [hrvBaseline, weight, weightPrediction, healthspanRaw, decisionContext] = await Promise.all( - [ + const [hrvBaseline, weight, bodyFat, weightPrediction, healthspanRaw, decisionContext] = + await Promise.all([ metricsRepo.getHrvBaseline(days, endDate, restingHeartRateCte), bodyRepo.getSmoothedWeight(weightDays, endDate), + bodyRepo.getRecomposition(days, endDate), bodyRepo.getWeightPrediction(weightDays, endDate, goalWeightKg), fetchHealthspanRawData( { @@ -270,8 +271,7 @@ export async function loadMobileRecoveryTab( captureException(error); return null; }), - ], - ); + ]); const restingHeartRateBaseline = baselineRelative.find( (metric) => metric.metric === "resting_heart_rate", @@ -339,6 +339,7 @@ export async function loadMobileRecoveryTab( trends: deriveTrends(dailyMetrics), dailyMetrics, weight, + bodyFat: bodyFat.map(({ date, bodyFatPct }) => ({ date, bodyFatPct })), decisionContext, weightPrediction, baselineRelative, diff --git a/packages/web/src/components/BodyFatPercentageChart.stories.tsx b/packages/web/src/components/BodyFatPercentageChart.stories.tsx new file mode 100644 index 0000000000..ea9516decd --- /dev/null +++ b/packages/web/src/components/BodyFatPercentageChart.stories.tsx @@ -0,0 +1,58 @@ +import { formatDateYmd } from "@dofek/format/format"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { BodyRecompositionRow } from "../../../server/src/routers/body-analytics.ts"; +import { UnitContext } from "../lib/unitContext.ts"; +import { BodyFatPercentageChart } from "./BodyFatPercentageChart.tsx"; + +function generateBodyFatData(): BodyRecompositionRow[] { + return Array.from({ length: 90 }, (_, index) => { + const date = formatDateYmd(new Date(2026, 1, 1 + index)); + const weightKg = 82 - index * 0.02; + const bodyFatPct = 24 - index * 0.035; + const fatMassKg = weightKg * (bodyFatPct / 100); + const leanMassKg = weightKg - fatMassKg; + return { + date, + weightKg, + bodyFatPct, + fatMassKg, + leanMassKg, + smoothedFatMass: fatMassKg, + smoothedLeanMass: leanMassKg, + }; + }); +} + +const meta = { + title: "Body/BodyFatPercentageChart", + component: BodyFatPercentageChart, + tags: ["autodocs"], + args: { + data: generateBodyFatData(), + }, + decorators: [ + (Story) => ( + {} }}> + + + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + loading: true, + }, +}; + +export const Empty: Story = { + args: { + data: [], + }, +}; diff --git a/packages/web/src/components/BodyFatPercentageChart.test.tsx b/packages/web/src/components/BodyFatPercentageChart.test.tsx new file mode 100644 index 0000000000..7ac6a078ea --- /dev/null +++ b/packages/web/src/components/BodyFatPercentageChart.test.tsx @@ -0,0 +1,181 @@ +/** @vitest-environment jsdom */ + +import { render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BodyRecompositionRow } from "../../../server/src/routers/body-analytics.ts"; +import { UnitContext } from "../lib/unitContext.ts"; + +let capturedOption: Record | null = null; + +vi.mock("echarts-for-react", () => ({ + default: ({ + option, + style, + }: { + option: Record; + style: Record; + }) => { + capturedOption = option; + return ( +
    + typeof value === "function" ? "[function]" : value, + )} + style={style satisfies React.CSSProperties} + /> + ); + }, +})); + +vi.mock("./LoadingSkeleton.tsx", () => ({ + ChartLoadingSkeleton: ({ height }: { height: number }) => ( +
    + ), +})); + +const { BodyFatPercentageChart } = await import("./BodyFatPercentageChart.tsx"); + +type TooltipFormatter = ( + params: Array<{ + seriesName: string; + marker?: string; + value?: [string, number]; + data?: unknown; + }>, +) => string; + +interface NamedSeries { + name: string; + type: string; + data: unknown[]; +} + +function isNamedSeries(value: unknown): value is NamedSeries { + return ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" && + "type" in value && + typeof value.type === "string" && + "data" in value && + Array.isArray(value.data) + ); +} + +const sampleData: BodyRecompositionRow[] = [ + { + date: "2026-02-28", + weightKg: 86, + bodyFatPct: 21.2, + fatMassKg: 18.23, + leanMassKg: 67.77, + smoothedFatMass: 18.23, + smoothedLeanMass: 67.77, + }, + { + date: "2026-03-01", + weightKg: 85.5, + bodyFatPct: 20.9, + fatMassKg: 17.87, + leanMassKg: 67.63, + smoothedFatMass: 17.87, + smoothedLeanMass: 67.63, + }, +]; + +function renderWithUnits(children: ReactNode) { + return render( + {} }}> + {children} + , + ); +} + +function getSeries(): NamedSeries { + const series = capturedOption?.series; + if (!Array.isArray(series) || series.length !== 1) { + throw new Error("Expected one chart series"); + } + const [chartSeries] = series; + if (!isNamedSeries(chartSeries)) { + throw new Error("Expected a named line series"); + } + return chartSeries; +} + +function getTooltipFormatter(): TooltipFormatter { + const tooltip = capturedOption?.tooltip; + if (typeof tooltip !== "object" || tooltip === null || !("formatter" in tooltip)) { + throw new Error("Expected tooltip.formatter to exist"); + } + const { formatter } = tooltip; + if (typeof formatter !== "function") { + throw new Error("Expected tooltip.formatter to be a function"); + } + return (params) => { + const result = formatter(params); + if (typeof result !== "string") throw new Error("Expected tooltip formatter to return string"); + return result; + }; +} + +describe("BodyFatPercentageChart", () => { + beforeEach(() => { + capturedOption = null; + }); + + it("renders one body-fat percentage line series with dated values", () => { + renderWithUnits(); + + expect(screen.getByTestId("echarts-mock")).toBeDefined(); + expect(getSeries()).toMatchObject({ + name: "Body Fat %", + type: "line", + data: [ + ["2026-02-28", 21.2], + ["2026-03-01", 20.9], + ], + }); + }); + + it("labels the body-fat y-axis with a percentage unit", () => { + renderWithUnits(); + + const yAxis = capturedOption?.yAxis; + if (typeof yAxis !== "object" || yAxis === null || !("name" in yAxis)) { + throw new Error("Expected a value y-axis"); + } + expect(yAxis.name).toBe("%"); + }); + + it("formats tooltip percentages and dates without floating-point noise", () => { + renderWithUnits(); + const formatter = getTooltipFormatter(); + const tooltipHtml = formatter([ + { + seriesName: "Body Fat %", + marker: '', + data: ["2026-03-01", 20.944], + value: ["2026-03-01", 20.944], + }, + ]); + + expect(tooltipHtml).toContain("Mar 1"); + expect(tooltipHtml).toContain("20.9%"); + expect(tooltipHtml).not.toContain("20.944"); + }); + + it("shows an explicit insufficient-data message for fewer than two readings", () => { + const singleRow = sampleData[0]; + if (!singleRow) throw new Error("Expected sample data"); + + const { container } = renderWithUnits(); + const scoped = within(container); + + expect(scoped.queryByTestId("echarts-mock")).toBeNull(); + expect(scoped.getByText(/at least two body-fat readings/i)).toBeDefined(); + }); +}); diff --git a/packages/web/src/components/BodyFatPercentageChart.tsx b/packages/web/src/components/BodyFatPercentageChart.tsx new file mode 100644 index 0000000000..09227f13f7 --- /dev/null +++ b/packages/web/src/components/BodyFatPercentageChart.tsx @@ -0,0 +1,73 @@ +import { formatBodyCompositionNumber, formatDateShort } from "@dofek/format/format"; +import type { BodyRecompositionRow } from "../../../server/src/routers/body-analytics.ts"; +import { + chartColors, + dofekAxis, + dofekGrid, + dofekLegend, + dofekSeries, + dofekTooltip, + escapeTooltipHtml, +} from "../lib/chartTheme.ts"; +import { DofekChart } from "./DofekChart.tsx"; + +interface BodyFatPercentageChartProps { + data: BodyRecompositionRow[]; + loading?: boolean; +} + +interface BodyFatTooltipParam { + seriesName?: string; + marker?: string; + data?: unknown; +} + +function isBodyFatDataPoint(value: unknown): value is [string, number] { + return Array.isArray(value) && typeof value[0] === "string" && typeof value[1] === "number"; +} + +export function BodyFatPercentageChart({ data, loading }: BodyFatPercentageChartProps) { + if (data.length < 2) { + return ( + + ); + } + + const option = { + grid: dofekGrid("single", { left: 50 }), + tooltip: dofekTooltip({ + formatter: (params: BodyFatTooltipParam[]) => { + if (!params || params.length === 0) return ""; + const firstDataPoint = params.find((param) => isBodyFatDataPoint(param.data))?.data; + if (!isBodyFatDataPoint(firstDataPoint)) return ""; + + const date = escapeTooltipHtml(formatDateShort(firstDataPoint[0])); + const lines = params.flatMap((param) => { + if (!isBodyFatDataPoint(param.data)) return []; + const marker = typeof param.marker === "string" ? param.marker : ""; + const seriesName = escapeTooltipHtml(param.seriesName ?? ""); + const displayValue = escapeTooltipHtml(`${formatBodyCompositionNumber(param.data[1])}%`); + return `${marker}${seriesName} ${displayValue}`; + }); + return `
    ${date}
    ${lines.join("
    ")}`; + }, + }), + legend: dofekLegend(true), + xAxis: dofekAxis.time(), + yAxis: dofekAxis.value({ name: "%" }), + series: [ + dofekSeries.line( + "Body Fat %", + data.map((row) => [row.date, row.bodyFatPct] satisfies [string, number]), + { color: chartColors.purple }, + ), + ], + }; + + return ; +} diff --git a/packages/web/src/components/DataSourcesPanel.test.tsx b/packages/web/src/components/DataSourcesPanel.test.tsx index 225a95a888..6b3fbf0a2f 100644 --- a/packages/web/src/components/DataSourcesPanel.test.tsx +++ b/packages/web/src/components/DataSourcesPanel.test.tsx @@ -99,6 +99,7 @@ vi.mock("../lib/trpc.ts", () => ({ providers: { invalidate: vi.fn() }, syncStatus: { fetch: mockSyncStatusFetch }, }, + processing: { status: { invalidate: vi.fn() } }, }), }, })); diff --git a/packages/web/src/pages/BodyPage.test.tsx b/packages/web/src/pages/BodyPage.test.tsx index dd4d871799..07441419e4 100644 --- a/packages/web/src/pages/BodyPage.test.tsx +++ b/packages/web/src/pages/BodyPage.test.tsx @@ -19,6 +19,11 @@ vi.mock("../components/BodyRecompositionChart.tsx", () => ({
    Recomposition points: {data.length}
    ), })); +vi.mock("../components/BodyFatPercentageChart.tsx", () => ({ + BodyFatPercentageChart: ({ data }: { data: unknown[] }) => ( +
    Body fat points: {data.length}
    + ), +})); vi.mock("../components/CorrelationCard.tsx", () => ({ CorrelationCard: () => null, CorrelationCardSkeleton: () => null, @@ -185,6 +190,12 @@ beforeEach(() => { afterEach(cleanup); describe("BodyPage", () => { + it("shows body fat points from recomposition data", () => { + render(); + + expect(screen.getByText("Body fat points: 1")).toBeTruthy(); + }); + it("shows one dependency notice for a repeated body-composition query failure", () => { queryMocks.weightOverview.mockReturnValue( mockQuery({ error: new Error("Body measurements are unavailable.") }), @@ -244,6 +255,7 @@ describe("BodyPage", () => { expect(screen.getByText("Smoothed weight points: 1")).toBeTruthy(); expect(screen.getByText("Recomposition points: 1")).toBeTruthy(); + expect(screen.getByText("Body fat points: 1")).toBeTruthy(); expect(screen.getByText("Weight prediction")).toBeTruthy(); expect(screen.getAllByText("Body data refresh failed.")).toHaveLength(1); expect( diff --git a/packages/web/src/pages/BodyPage.tsx b/packages/web/src/pages/BodyPage.tsx index 861f0309fa..0ff49ee6f0 100644 --- a/packages/web/src/pages/BodyPage.tsx +++ b/packages/web/src/pages/BodyPage.tsx @@ -5,6 +5,7 @@ import { healthStatusMetricSchema } from "dofek-server/mobile-dashboard-contract import { useMemo } from "react"; import { z } from "zod"; import { BodyDecisionContext } from "../components/BodyDecisionContext.tsx"; +import { BodyFatPercentageChart } from "../components/BodyFatPercentageChart.tsx"; import { BodyRecompositionChart } from "../components/BodyRecompositionChart.tsx"; import { ChartDescriptionTooltip } from "../components/ChartDescriptionTooltip.tsx"; import { @@ -323,7 +324,7 @@ export function BodyPage() { ) : null}
    {!weightOverviewUnavailable && ( -
    +

    Trend Weight

    @@ -349,6 +350,15 @@ export function BodyPage() { loading={weightOverview.isLoading} />
    +
    +
    +

    Body Fat Percentage

    +
    + +
    )} From bd2aecefe26726479c5bda0cabdc52b2964c0b3d Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 11:55:40 -0700 Subject: [PATCH 28/46] fix(peloton): accept omitted total work (#2472) --- docs/production-incident-baseline.md | 27 +++++++++++++++++++++ packages/peloton-client/src/client.test.ts | 28 ++++++++++++++++++++++ packages/peloton-client/src/types.ts | 2 +- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index d37d91b557..41874d2f30 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -7,6 +7,33 @@ full incident log or a replacement for runbooks. Use it to build shared memory about the kinds of issues this system encounters, the signals that identified them, and the durability work they suggest. +## 2026-08-10: Peloton workouts omitted `total_work` + +- **Status:** Root cause identified and fixed in this workspace; deployment and + post-deploy Sentry verification remain pending. +- **Symptoms / user impact:** Peloton workout syncs failed with + `PelotonResponseError: Peloton returned an invalid workouts response`, so + affected sync runs could not ingest the returned workout page. The issue had + 64 occurrences and no directly affected Sentry users. See + [DOFEK-SERVER-5E](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5E/). +- **Evidence:** The latest event's Zod errors identify + `data[6]` through `data[19].total_work` as `undefined`; the failure occurs + while parsing the successful `/api/user/{userId}/workouts` response in + `packages/peloton-client/src/client.ts`. Existing Sentry error reporting + captured the complete validation paths, so no additional diagnostic + instrumentation was required. +- **Root cause:** `pelotonWorkoutSchema` accepted `total_work: null` but still + required the property to be present. Peloton omits that field for some + workout records, making the schema stricter than the observed API contract. +- **Fix:** Changed `total_work` to `z.number().nullish()` and added a client + regression test covering a workout response where Peloton omits the field. +- **Validation:** The red regression test failed with the same Zod validation + path reported by Sentry; after the schema change, the Peloton client and + parser suites passed (18 tests). +- **Remaining risk / follow-up:** Deploy the fix and verify that + `DOFEK-SERVER-5E` stops receiving new events. No retry, timeout, or fallback + was added because the root cause was a local response-contract mismatch. + ## 2026-08-10: Pull-request CI failures from configuration and test drift - **Status:** Repository fixes are pushed; the latest CI run has no failed diff --git a/packages/peloton-client/src/client.test.ts b/packages/peloton-client/src/client.test.ts index c9097761eb..9c15383bc8 100644 --- a/packages/peloton-client/src/client.test.ts +++ b/packages/peloton-client/src/client.test.ts @@ -154,6 +154,34 @@ describe("PelotonClient", () => { await expect(client.getWorkouts()).resolves.toEqual(response); }); + it("accepts workouts where Peloton omits total work", async () => { + const response = { + data: [ + { + id: "workout-2", + status: "COMPLETE", + fitness_discipline: "strength", + created_at: 1_709_280_000, + start_time: 1_709_280_000, + end_time: 1_709_281_800, + is_total_work_personal_record: false, + }, + ], + total: 1, + count: 1, + page: 0, + limit: 20, + page_count: 1, + sort_by: "-created_at", + show_next: false, + show_previous: false, + }; + const responses = [Response.json({ id: "user-123" }), Response.json(response)]; + const client = new PelotonClient("secret", async () => responses.shift() ?? Response.error()); + + await expect(client.getWorkouts()).resolves.toEqual(response); + }); + it("accepts numeric performance summaries (DOFEK-SERVER-5F)", async () => { const graph = { duration: 5, diff --git a/packages/peloton-client/src/types.ts b/packages/peloton-client/src/types.ts index 36c4db3b7a..001bdf2361 100644 --- a/packages/peloton-client/src/types.ts +++ b/packages/peloton-client/src/types.ts @@ -25,7 +25,7 @@ export const pelotonWorkoutSchema = z.object({ created_at: z.number(), start_time: z.number(), end_time: z.number().nullable(), - total_work: z.number().nullable(), + total_work: z.number().nullish(), is_total_work_personal_record: z.boolean(), metrics_type: z.string().nullish(), device_type: z.string().optional(), From 9f31ddd9efd09367171657a26fc06c31088771bf Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 13:37:50 -0700 Subject: [PATCH 29/46] Fix production Sentry failures --- .../models/read_models/cycling_activity.sql | 2 +- .../read_models/cycling_activity.sql.test.ts | 4 ++ .../read_models/sleep_heart_rate_sample.sql | 15 +++--- .../sleep_heart_rate_sample.sql.test.ts | 14 +++++ docs/production-incident-baseline.md | 54 +++++++++++++++++++ ...2_processing_stage_event_latest_lookup.sql | 11 ++++ drizzle/meta/_journal.json | 7 +++ packages/mobile/lib/query-client.test.ts | 25 ++++++++- packages/mobile/lib/query-client.ts | 10 ++++ .../activities-calendar-repository.test.ts | 4 +- .../activities-calendar-repository.ts | 2 +- .../repositories/activity-repository.test.ts | 16 ++++++ .../src/repositories/activity-repository.ts | 30 +++++++++++ .../cycling-analytics-repository.test.ts | 19 +++++++ .../cycling-analytics-repository.ts | 5 +- src/db/schema/processing.ts | 8 +++ 16 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 drizzle/0072_processing_stage_event_latest_lookup.sql diff --git a/analytics/models/read_models/cycling_activity.sql b/analytics/models/read_models/cycling_activity.sql index 9eca6475ab..a8e5739f31 100644 --- a/analytics/models/read_models/cycling_activity.sql +++ b/analytics/models/read_models/cycling_activity.sql @@ -62,7 +62,7 @@ active_cycling AS ( identity.source_providers AS source_providers, assumeNotNull(summary.canonical_type) AS canonical_type, summary.provider_type AS provider_type, - summary.modality AS modality, + nullIf(summary.modality, '') AS modality, summary.name AS activity_name, assumeNotNull(summary.started_at) AS started_at, summary.ended_at AS ended_at, diff --git a/analytics/models/read_models/cycling_activity.sql.test.ts b/analytics/models/read_models/cycling_activity.sql.test.ts index 650f71b81c..30f66a1fb5 100644 --- a/analytics/models/read_models/cycling_activity.sql.test.ts +++ b/analytics/models/read_models/cycling_activity.sql.test.ts @@ -7,4 +7,8 @@ describe("cycling_activity model", () => { it("preserves nulls when an activity has no aerobic efficiency row", () => { expect(modelSql).toContain("'join_use_nulls': 1"); }); + + it("normalizes empty modalities to null at the serving boundary", () => { + expect(modelSql).toContain("nullIf(summary.modality, '') AS modality"); + }); }); diff --git a/analytics/models/read_models/sleep_heart_rate_sample.sql b/analytics/models/read_models/sleep_heart_rate_sample.sql index 253590721e..cc0fb12427 100644 --- a/analytics/models/read_models/sleep_heart_rate_sample.sql +++ b/analytics/models/read_models/sleep_heart_rate_sample.sql @@ -47,6 +47,12 @@ existing_sleep_state AS materialized ( max(refreshed_at) AS refreshed_at, countIf(is_deleted = 0) > 0 AS has_active_samples FROM {{ this }} FINAL + WHERE (user_id, sleep_id) IN ( + SELECT + user_id, + sleep_id + FROM current_windows + ) GROUP BY user_id, sleep_id @@ -163,6 +169,9 @@ current_samples AS ( greatest(samples.refreshed_at, active_dirty_sleep.source_refreshed_at) AS source_refreshed_at FROM {{ ref('deduped_sensor') }} AS samples FINAL + INNER JOIN dirty_sleep_dates + ON dirty_sleep_dates.user_id = samples.user_id + AND dirty_sleep_dates.recorded_date = samples.recorded_date INNER JOIN active_dirty_sleep ON active_dirty_sleep.user_id = samples.user_id AND samples.recorded_at >= active_dirty_sleep.started_at @@ -177,12 +186,6 @@ current_samples AS ( WHERE samples.channel = 'heart_rate' AND samples.is_deleted = 0 AND samples.scalar IS NOT NULL - AND (samples.user_id, samples.recorded_date) IN ( - SELECT - user_id, - recorded_date - FROM dirty_sleep_dates - ) AND overlapping_activity.id IS NULL ), diff --git a/analytics/models/read_models/sleep_heart_rate_sample.sql.test.ts b/analytics/models/read_models/sleep_heart_rate_sample.sql.test.ts index e1099acb7f..ab2e5a4bbc 100644 --- a/analytics/models/read_models/sleep_heart_rate_sample.sql.test.ts +++ b/analytics/models/read_models/sleep_heart_rate_sample.sql.test.ts @@ -18,6 +18,20 @@ describe("sleep_heart_rate_sample model", () => { expect(dirtyKeysSql).toContain("LIMIT {{ sleep_dirty_key_batch_size }}"); }); + it("bounds existing state and sensor reads to the current dirty source keys", async () => { + const existingStateSql = extractCteSql(modelSql, "existing_sleep_state").replace(/\s+/g, " "); + const currentSamplesSql = extractCteSql(modelSql, "current_samples").replace(/\s+/g, " "); + + expect(existingStateSql).toContain( + "(user_id, sleep_id) IN ( SELECT user_id, sleep_id FROM current_windows )", + ); + expect(currentSamplesSql).toContain("INNER JOIN dirty_sleep_dates"); + expect(currentSamplesSql).toContain("dirty_sleep_dates.user_id = samples.user_id"); + expect(currentSamplesSql).toContain( + "dirty_sleep_dates.recorded_date = samples.recorded_date", + ); + }); + it("only emits lifecycle tombstones for previously active samples", () => { const staleKeysSql = extractCteSql(modelSql, "stale_sleep_dirty_keys").replace(/\s+/g, " "); diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index e5170c0d30..e2f965f7c9 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -7,6 +7,60 @@ full incident log or a replacement for runbooks. Use it to build shared memory about the kinds of issues this system encounters, the signals that identified them, and the durability work they suggest. +## 2026-08-10 — Sentry production failures from stale analytics data and pool starvation + +- **Status:** Fix prepared; deployment and post-release verification are pending. Affected unresolved issues were [DOFEK-MOBILE-19](https://east-bay-software.sentry.io/issues/DOFEK-MOBILE-19), [DOFEK-MOBILE-1F](https://east-bay-software.sentry.io/issues/DOFEK-MOBILE-1F), [DOFEK-MOBILE-1G](https://east-bay-software.sentry.io/issues/DOFEK-MOBILE-1G), [DOFEK-SERVER-5K](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5K), [DOFEK-SERVER-5Y](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5Y), [DOFEK-SERVER-5Z](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5Z), and [DOFEK-SERVER-58](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-58). +- **Symptoms / impact:** Mobile dashboard and IMU uploads intermittently failed; the training dashboard rejected one cycling row; analytics builds timed out while reading sleep heart-rate samples; and session validation/calendar activity lookups failed with PostgreSQL connection-pool timeouts. +- **Evidence / root cause:** Sentry showed an empty-string `modality` in `analytics.cycling_activity`, which the server enum rejected. ClickHouse query logs showed `sleep_heart_rate_sample` timing out at the 240-second limit while scanning 1.87 million existing rows and 13.2 million deduped sensor rows despite a 32-key incremental batch. PostgreSQL query statistics showed the scoped processing query reaching 43.2 seconds and the `fitness.v_activity` visibility query reaching 8.1 seconds; Sentry's nested error was `timeout exceeded when trying to connect`, while production PostgreSQL remained healthy at `max_connections = 40` with no crash, recovery, or disk-pressure evidence. Mobile network-loss events were expected transport failures reported by the React Query global error hook. +- **Fix / mitigation:** Normalize empty modalities to null in both the ClickHouse model and server boundary; restrict sleep sample state and sensor reads to current dirty windows/dates; add an ordered latest-event index for scoped processing history; use a bounded base-table lookup when filtering already-canonical calendar rows instead of expanding the recursive visibility view; and stop sending known transient mobile transport errors to Sentry while preserving reporting for server and parse failures. No timeout, retry, pool-size increase, or warning-and-continue workaround was added. +- **Validation:** Regression tests pass for the mobile telemetry filter, cycling modality normalization, bounded sleep model SQL, and latest-event model behavior. Production read-only checks confirmed the database and service health before the change. +- **Remaining risk / follow-up:** Apply the migration and deploy the release, then verify that all seven issue IDs stop receiving production events and that the next analytics build completes below its timeout. Confirm that the direct calendar visibility lookup preserves canonical-row authorization for all production read-model paths. + +## 2026-08-10 — Dependabot updates blocked by mismatched CI baselines + +- **Status:** Resolved. Dependabot PRs [#2463](https://github.com/Asherlc/dofek/pull/2463), [#2455](https://github.com/Asherlc/dofek/pull/2455), and [#2450](https://github.com/Asherlc/dofek/pull/2450) were merged; duplicate CodeQL PRs [#2461](https://github.com/Asherlc/dofek/pull/2461) and [#2456](https://github.com/Asherlc/dofek/pull/2456), plus incompatible mobile PR [#2460](https://github.com/Asherlc/dofek/pull/2460), were closed. No production impact occurred. +- **Evidence / root cause:** CodeQL reported `Loaded a configuration file for version '4.37.3', but running version '4.37.4'` in [job 93158091311](https://github.com/Asherlc/dofek/actions/runs/31279367655/job/93158091311) because Dependabot split one workflow upgrade across three action PRs. The mobile job's first fatal command was `pnpm expo install --check`, which rejected `react-native-maps@1.29.0` while Expo SDK 57 expected `1.27.2` ([Expo dependency validation](https://docs.expo.dev/more/expo-cli/#dependency-validation)). The S3 PR failed typechecking because `S3Client` from the pinned client package was incompatible with the newer presigner in [job 93158710273](https://github.com/Asherlc/dofek/actions/runs/31279188149/job/93158710273). The vcpkg PR failed with `no version database entry for vcpkg-cmake-config at 2026-07-21` in [job 93157522927](https://github.com/Asherlc/dofek/actions/runs/31279106911/job/93157522927), because CI bootstrapped an older vcpkg commit than the requested manifest baseline. +- **Fix / mitigation:** Aligned all CodeQL actions to the latest pinned v4.37.6 commit, aligned the S3 client and presigner at 3.1106.0, and aligned the native workflow and image vcpkg pins with the 2026.07.29 baseline. The Expo-incompatible maps update was closed rather than bypassing the canonical compatibility check. +- **Validation:** The final exact-head run [31355357176](https://github.com/Asherlc/dofek/actions/runs/31355357176) completed with 2,226 passed checks and zero failures before PR #2450 merged at commit `cb2ebb029306561635c31945997a093cc4a44b90`. A fresh Dependabot search reports no open PRs. +- **Remaining risk / follow-up:** Keep CodeQL action components grouped or version-aligned in future Dependabot updates, and treat Expo compatibility failures as dependency-selection issues rather than adding exclusions or warning-only behavior. + +## 2026-08-09 — PostHog Sentry summary warehouse sync rejected by upstream API + +- **Status:** Unresolved external integration issue; no Dofek application or + deployment change was made. +- **Symptoms / impact:** PostHog project Dofek reported the Sentry + `organization_stats_summary` warehouse sync as failed. The schema has never + materialized a table, and every observed run synced zero rows; the summary + data remains unavailable until the sync succeeds or the schema is disabled. +- **Evidence / root cause:** PostHog’s live source history shows 43 consecutive + failed full-refresh runs from 2026-07-29 through 2026-08-09. The failures + consistently return HTTP 400 from + `https://sentry.io/api/0/organizations/east-bay-software/stats-summary/`. + Earlier requests used `statsPeriod=90d`; later requests used explicit + `start`/`end` timestamps. All other enabled Sentry schemas completed, so + credentials and general source connectivity are working. PostHog’s first + fix for this incident changed the request away from the 90-day retention + boundary to explicit `start`/`end` values and was deployed on 2026-08-07 + ([PR #79517](https://github.com/PostHog/posthog/pull/79517)); Dofek continued + failing afterward. PostHog’s follow-up ([PR #80099](https://github.com/PostHog/posthog/pull/80099)) + identifies the remaining pattern as another deterministic Sentry 400, + usually caused by a requested range outside the Sentry plan’s retention, and + notes that the upstream response body is not preserved. Sentry’s current API + documentation lists `sum(quantity)` and either `statsPeriod` or `start`/`end` + as valid parameters ([official API reference](https://docs.sentry.io/api/organizations/retrieve-an-organizations-events-count-by-project/)), + so the exact Dofek-specific rejection remains unconfirmed without a direct + read-only Sentry request. +- **Fix / mitigation:** No retry or configuration workaround was applied; + automatic retries are ineffective while the same request is rejected. The + recommended next action is to disable this unused optional schema, or open a + PostHog support case with the source ID, schema ID, and failed workflow ID if + the summary table is required. +- **Remaining risk / follow-up:** Decide whether + `organization_stats_summary` is needed. If it is, obtain the Sentry 400 + response body from PostHog support or Sentry and repair the connector before + re-enabling it; if not, disable the schema to stop repeated failed billable + sync attempts. + ## 2026-08-07 — Wahoo OAuth callback served as `Not Found` - **Status:** Root cause identified; the PWA update fix is implemented in this diff --git a/drizzle/0072_processing_stage_event_latest_lookup.sql b/drizzle/0072_processing_stage_event_latest_lookup.sql new file mode 100644 index 0000000000..6f3723b77c --- /dev/null +++ b/drizzle/0072_processing_stage_event_latest_lookup.sql @@ -0,0 +1,11 @@ +-- Keep latest-event lookups ordered by their DISTINCT ON keys so scoped +-- processing history does not sort every event row for each operation. +CREATE INDEX processing_stage_event_latest_idx -- noqa: PG01 +ON fitness.processing_stage_event USING btree ( + operation_id, + stage, + dataset_key, + output_path, + model_name, + sequence DESC NULLS LAST +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b0e4d2f3b1..30be8d2c6e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -589,6 +589,13 @@ "when": 1786230300000, "tag": "0071_processing_alert_dismissal", "breakpoints": true + }, + { + "idx": 84, + "version": "7", + "when": 1786380000000, + "tag": "0072_processing_stage_event_latest_lookup", + "breakpoints": true } ] } diff --git a/packages/mobile/lib/query-client.test.ts b/packages/mobile/lib/query-client.test.ts index 3fb279638a..8d0669cffe 100644 --- a/packages/mobile/lib/query-client.test.ts +++ b/packages/mobile/lib/query-client.test.ts @@ -1,5 +1,5 @@ import { environmentManager, QueryObserver } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createAppQueryClient } from "./query-client"; const mockCaptureException = vi.hoisted(() => vi.fn()); @@ -8,6 +8,10 @@ vi.mock("./telemetry", () => ({ captureException: mockCaptureException, })); +beforeEach(() => { + mockCaptureException.mockClear(); +}); + describe("createAppQueryClient", () => { it("does not automatically retry a failed query", async () => { const queryClient = createAppQueryClient(); @@ -57,4 +61,23 @@ describe("createAppQueryClient", () => { failureCount: 1, }); }); + + it("does not report transient network transport errors to Sentry", async () => { + const queryClient = createAppQueryClient(); + const queryError = new Error( + "fetch failed: UnexpectedException: The network connection was lost.", + ); + + await expect( + queryClient.fetchQuery({ + queryKey: ["offline-query"], + retry: false, + queryFn: async () => { + throw queryError; + }, + }), + ).rejects.toThrow(queryError); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mobile/lib/query-client.ts b/packages/mobile/lib/query-client.ts index b14e185157..bb847cfca4 100644 --- a/packages/mobile/lib/query-client.ts +++ b/packages/mobile/lib/query-client.ts @@ -2,10 +2,20 @@ import { QUERY_CACHE_MAX_AGE_MS } from "@dofek/scoring/query-cache"; import { QueryCache, QueryClient } from "@tanstack/react-query"; import { captureException } from "./telemetry"; +function isTransientNetworkError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /network connection was lost|request timed out|network request failed|fetch failed:.*(?:network connection|timed out|cancelled)/i.test( + message, + ); +} + export function createAppQueryClient() { return new QueryClient({ queryCache: new QueryCache({ onError(error, query) { + if (isTransientNetworkError(error)) { + return; + } captureException(error, { source: "react-query", queryHash: query.queryHash, diff --git a/packages/server/src/repositories/activities-calendar-repository.test.ts b/packages/server/src/repositories/activities-calendar-repository.test.ts index b19e4131ad..e692ebe0ed 100644 --- a/packages/server/src/repositories/activities-calendar-repository.test.ts +++ b/packages/server/src/repositories/activities-calendar-repository.test.ts @@ -30,7 +30,7 @@ function makeDatabase(rowsOrRowSets: TestDatabaseRow[] | TestDatabaseRow[][] = [ const execute = vi.fn().mockImplementation(async (query) => { const compiled = dialect.sqlToQuery(query); - if (compiled.sql.includes("fitness.v_activity")) { + if (compiled.sql.includes("fitness.v_activity") || compiled.sql.includes("fitness.activity")) { const stringParams = compiled.params.filter( (param): param is string => typeof param === "string", ); @@ -567,7 +567,7 @@ describe("ActivitiesCalendarRepository", () => { expect(database.execute).toHaveBeenCalledTimes(1); const sqlObject = database.execute.mock.calls[0]?.[0]; const compiledQuery = dialect.sqlToQuery(sqlObject); - expect(normalizeSql(compiledQuery.sql)).toContain("FROM fitness.v_activity"); + expect(normalizeSql(compiledQuery.sql)).toContain("FROM fitness.activity"); expect(sensorStore.query).toHaveBeenCalledTimes(2); }); diff --git a/packages/server/src/repositories/activities-calendar-repository.ts b/packages/server/src/repositories/activities-calendar-repository.ts index 4b6cda19a3..af9da8509c 100644 --- a/packages/server/src/repositories/activities-calendar-repository.ts +++ b/packages/server/src/repositories/activities-calendar-repository.ts @@ -291,7 +291,7 @@ export class ActivitiesCalendarRepository extends BaseRepository { this.userId, this.timezone, this.accessWindow, - ).filterToVisibleActivities(activityRowsMatchingType); + ).filterToVisibleCanonicalActivities(activityRowsMatchingType); const locationActivityIds = filteredActivityRows .filter((row) => row.centroid_lat != null && row.centroid_lng != null) .map((row) => row.id); diff --git a/packages/server/src/repositories/activity-repository.test.ts b/packages/server/src/repositories/activity-repository.test.ts index b1818fe38c..9b4da2b93d 100644 --- a/packages/server/src/repositories/activity-repository.test.ts +++ b/packages/server/src/repositories/activity-repository.test.ts @@ -297,6 +297,22 @@ describe("ActivityRepository", () => { expect(filtered).toEqual([{ id: "activity-1", name: "Run" }]); }); + + it("filters already-canonical activity rows without expanding v_activity", async () => { + const { repo, execute } = makeRepository([{ id: "activity-1" }]); + + const filtered = await repo.filterToVisibleCanonicalActivities([ + { id: "activity-1", name: "Run" }, + { id: "activity-2", name: "Ride" }, + ]); + + expect(filtered).toEqual([{ id: "activity-1", name: "Run" }]); + const compiledQuery = dialect.sqlToQuery(execute.mock.calls[0]?.[0]); + expect(compiledQuery.sql).toContain("FROM fitness.activity"); + expect(compiledQuery.sql).not.toContain("FROM fitness.v_activity"); + expect(compiledQuery.sql).toContain("provider_absent_at IS NULL"); + expect(compiledQuery.sql).toContain("deleted_at IS NULL"); + }); }); describe("list", () => { diff --git a/packages/server/src/repositories/activity-repository.ts b/packages/server/src/repositories/activity-repository.ts index ac6a166ef9..09f8e1ff75 100644 --- a/packages/server/src/repositories/activity-repository.ts +++ b/packages/server/src/repositories/activity-repository.ts @@ -399,6 +399,36 @@ export class ActivityRepository extends BaseRepository { return rows.filter((row) => visibleActivityIds.has(getActivityId(row))); } + /** + * Filters rows whose IDs are already canonicalized by the ClickHouse activity + * read model without expanding the recursive PostgreSQL visibility view. + */ + async filterToVisibleCanonicalActivities( + rows: readonly T[], + ): Promise { + const uniqueActivityIds = [...new Set(rows.map(readActivityId))]; + if (uniqueActivityIds.length === 0) { + return []; + } + + const activityIdFilter = sql.join( + uniqueActivityIds.map((activityId) => sql`${activityId}::uuid`), + sql`, `, + ); + const visibleRows = await this.query( + z.object({ id: z.string() }), + sql`SELECT id::text AS id + FROM fitness.activity + WHERE user_id = ${this.userId}::uuid + AND id IN (${activityIdFilter}) + AND provider_absent_at IS NULL + AND deleted_at IS NULL + ${this.timestampAccessPredicate(sql`started_at`)}`, + ); + const visibleActivityIds = new Set(visibleRows.map((row) => row.id)); + return rows.filter((row) => visibleActivityIds.has(row.id)); + } + /** Counts visible activities in fitness.v_activity for the requested window. */ async countVisibleInWindow(input: CountVisibleInWindowInput): Promise { const activityTypePredicate = diff --git a/packages/server/src/repositories/cycling-analytics-repository.test.ts b/packages/server/src/repositories/cycling-analytics-repository.test.ts index 4f29354b7d..88a698f97f 100644 --- a/packages/server/src/repositories/cycling-analytics-repository.test.ts +++ b/packages/server/src/repositories/cycling-analytics-repository.test.ts @@ -930,6 +930,25 @@ describe("CyclingAnalyticsRepository", () => { ]); }); + it("treats an empty modality from the read model as unknown", async () => { + const sensorStore = makeMockSensorStore([cyclingActivityRow({ modality: "" })]); + const repository = new CyclingAnalyticsRepository( + { execute: vi.fn().mockResolvedValue([]) }, + "11111111-1111-4111-8111-111111111111", + "UTC", + sensorStore, + ); + + await expect( + repository.getActivities(ChartRange.fromDays(90), { + activityLimit: 20, + activityOffset: 0, + variabilityLimit: 20, + variabilityOffset: 0, + }), + ).resolves.toBeDefined(); + }); + it("returns the cycling empty state when there are no activities", async () => { const sensorStore = makeMockSensorStore([]); const repository = new CyclingAnalyticsRepository( diff --git a/packages/server/src/repositories/cycling-analytics-repository.ts b/packages/server/src/repositories/cycling-analytics-repository.ts index 9cf6d836b2..470d775d9b 100644 --- a/packages/server/src/repositories/cycling-analytics-repository.ts +++ b/packages/server/src/repositories/cycling-analytics-repository.ts @@ -50,7 +50,10 @@ const cyclingActivityRowSchema = z.object({ started_at: timestampStringSchema, ended_at: timestampStringSchema.nullable(), canonical_type: z.string(), - modality: z.enum(ACTIVITY_MODALITIES).nullable(), + modality: z.preprocess( + (value) => (value === "" ? null : value), + z.enum(ACTIVITY_MODALITIES).nullable(), + ), activity_name: z.string().nullable(), provider_id: z.string(), source_providers: z.array(z.string()), diff --git a/src/db/schema/processing.ts b/src/db/schema/processing.ts index 456c2d813c..c4048735b6 100644 --- a/src/db/schema/processing.ts +++ b/src/db/schema/processing.ts @@ -121,6 +121,14 @@ export const processingStageEvent = fitness.table( table.operationId, table.sequence.desc(), ), + index("processing_stage_event_latest_idx").on( + table.operationId, + table.stage, + table.datasetKey, + table.outputPath, + table.modelName, + table.sequence.desc(), + ), index("processing_stage_event_dataset_sequence_idx").on( table.datasetKey, table.sequence.desc(), From 94816d009af4345f3ea657f9dbbf4f068bdcb497 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 14:29:11 -0700 Subject: [PATCH 30/46] feat: add Hangboarding import, contracts, and UI (#2471) --- .github/dependabot.yml | 16 +- .github/workflows/build-docker-ml.yml | 2 +- .github/workflows/build-docker.yml | 4 +- .github/workflows/codeql.yml | 6 +- .github/workflows/deploy-web-stack.yml | 2 +- .github/workflows/deploy.yml | 4 +- .github/workflows/semgrep.yml | 2 +- .github/workflows/test.yml | 6 +- .../task-3-report.md | 120 ++ Dockerfile | 5 +- cspell.json | 12 + docker-compose.e2e.yml | 2 +- docker-compose.peerdb.yml | 2 +- docker-compose.yml | 2 +- docs/hang-ten.md | 34 + docs/production-incident-baseline.md | 46 + docs/schema.dbml | 1 + ...2026-08-07-hang-ten-apple-health-import.md | 811 ++++++++ .../2026-08-10-hangboarding-import-and-ui.md | 820 ++++++++ ...-07-hang-ten-apple-health-import-design.md | 121 ++ .../2026-08-10-hangboarding-ui-design.md | 135 ++ drizzle/0072_add_hangboard_activity_type.sql | 2 + drizzle/meta/_journal.json | 7 + native/fit-decoder/vcpkg-configuration.json | 2 +- package.json | 6 +- packages/eight-sleep/package.json | 2 +- packages/garmin-connect/package.json | 2 +- packages/ml/pyproject.toml | 2 +- .../app-tests/(tabs)/activities.test.tsx | 43 + .../mobile/app-tests/(tabs)/strain.test.tsx | 131 +- .../mobile/app-tests/activity/[id].test.tsx | 49 + packages/mobile/app/(tabs)/strain.tsx | 91 +- packages/mobile/app/activity/[id].tsx | 31 + .../components/HangboardingDetail.stories.tsx | 50 + .../components/HangboardingDetail.test.tsx | 113 ++ .../mobile/components/HangboardingDetail.tsx | 149 ++ .../HangboardingSummary.stories.tsx | 60 + .../components/HangboardingSummary.test.tsx | 123 ++ .../mobile/components/HangboardingSummary.tsx | 147 ++ .../mobile/modules/whoop-ble/package.json | 2 +- packages/mobile/package.json | 6 +- packages/peloton-client/package.json | 2 +- packages/provider-http/package.json | 2 +- packages/scoring/package.json | 2 +- .../mobile-dashboard-contracts.test.ts | 12 + .../contracts/mobile-dashboard-contracts.ts | 28 + .../activities-calendar-repository.test.ts | 140 +- .../activities-calendar-repository.ts | 38 +- ...visibility-consistency.integration.test.ts | 52 +- ...angboarding-repository.integration.test.ts | 287 +++ .../hangboarding-repository.test.ts | 337 ++++ .../repositories/hangboarding-repository.ts | 384 ++++ .../src/routers/activity.integration.test.ts | 61 +- packages/server/src/routers/activity.test.ts | 55 + packages/server/src/routers/activity.ts | 20 + .../src/routers/climbing.integration.test.ts | 102 + packages/server/src/routers/climbing.test.ts | 70 + packages/server/src/routers/climbing.ts | 13 + .../src/routers/mobile-dashboard.test.ts | 36 + .../src/services/mobile-training-tab.test.ts | 52 +- .../src/services/mobile-training-tab.ts | 10 + packages/trainerroad-client/package.json | 2 +- packages/training/package.json | 2 +- packages/training/src/activity-types.test.ts | 14 + packages/training/src/activity-types.ts | 1 + packages/training/src/training.test.ts | 6 + packages/training/src/training.ts | 1 + packages/trainingpeaks-connect/package.json | 2 +- packages/velohero-client/package.json | 2 +- packages/web/package.json | 8 +- .../components/HangboardingDetail.stories.tsx | 59 + .../components/HangboardingDetail.test.tsx | 86 + .../web/src/components/HangboardingDetail.tsx | 117 ++ .../HangboardingSummary.stories.tsx | 70 + .../components/HangboardingSummary.test.tsx | 138 ++ .../src/components/HangboardingSummary.tsx | 115 ++ .../web/src/pages/ActivitiesPage.test.tsx | 41 + .../web/src/pages/ActivityDetailPage.test.tsx | 52 + packages/web/src/pages/ActivityDetailPage.tsx | 24 + .../web/src/routes/training/climbing.test.tsx | 11 + packages/web/src/routes/training/climbing.tsx | 23 + packages/whoop-whoop/package.json | 2 +- packages/xert-client/package.json | 2 +- packages/zepp-client/package.json | 2 +- packages/zones/package.json | 2 +- packages/zwift-client/package.json | 2 +- paseo.json | 53 - pnpm-lock.yaml | 1646 ++++++++++------- pnpm-workspace.yaml | 4 +- ...tivity-types-migration.integration.test.ts | 37 +- ...ctivity-type-migration.integration.test.ts | 33 + .../db-insertion.integration.test.ts | 182 +- .../apple-health/db-insertion.test.ts | 181 +- src/providers/apple-health/db-insertion.ts | 110 +- .../apple-health/hang-ten-intervals.test.ts | 93 + .../apple-health/hang-ten-intervals.ts | 84 + .../apple-health/import.integration.test.ts | 64 +- src/providers/apple-health/import.test.ts | 68 + src/providers/apple-health/import.ts | 17 +- .../apple-health/parsing-extra.test.ts | 67 + src/providers/apple-health/parsing.test.ts | 215 +++ src/providers/apple-health/streaming.test.ts | 89 + src/providers/apple-health/streaming.ts | 12 + src/providers/apple-health/test-helpers.ts | 43 + src/providers/apple-health/workouts.ts | 132 +- vendor/image-size/LICENSE | 9 + vendor/image-size/Readme.md | 170 ++ vendor/image-size/bin/image-size.js | 51 + vendor/image-size/dist/detector.d.ts | 2 + vendor/image-size/dist/detector.js | 30 + vendor/image-size/dist/index.d.ts | 10 + vendor/image-size/dist/index.js | 129 ++ vendor/image-size/dist/types/bmp.d.ts | 2 + vendor/image-size/dist/types/bmp.js | 11 + vendor/image-size/dist/types/cur.d.ts | 2 + vendor/image-size/dist/types/cur.js | 17 + vendor/image-size/dist/types/dds.d.ts | 2 + vendor/image-size/dist/types/dds.js | 11 + vendor/image-size/dist/types/gif.d.ts | 2 + vendor/image-size/dist/types/gif.js | 12 + vendor/image-size/dist/types/heif.d.ts | 2 + vendor/image-size/dist/types/heif.js | 40 + vendor/image-size/dist/types/icns.d.ts | 2 + vendor/image-size/dist/types/icns.js | 109 ++ vendor/image-size/dist/types/ico.d.ts | 2 + vendor/image-size/dist/types/ico.js | 68 + vendor/image-size/dist/types/index.d.ts | 23 + vendor/image-size/dist/types/index.js | 46 + vendor/image-size/dist/types/interface.d.ts | 13 + vendor/image-size/dist/types/interface.js | 2 + vendor/image-size/dist/types/j2c.d.ts | 2 + vendor/image-size/dist/types/j2c.js | 12 + vendor/image-size/dist/types/jp2.d.ts | 2 + vendor/image-size/dist/types/jp2.js | 27 + vendor/image-size/dist/types/jpg.d.ts | 2 + vendor/image-size/dist/types/jpg.js | 123 ++ vendor/image-size/dist/types/jxl-stream.d.ts | 2 + vendor/image-size/dist/types/jxl-stream.js | 45 + vendor/image-size/dist/types/jxl.d.ts | 2 + vendor/image-size/dist/types/jxl.js | 59 + vendor/image-size/dist/types/ktx.d.ts | 2 + vendor/image-size/dist/types/ktx.js | 19 + vendor/image-size/dist/types/png.d.ts | 2 + vendor/image-size/dist/types/png.js | 35 + vendor/image-size/dist/types/pnm.d.ts | 2 + vendor/image-size/dist/types/pnm.js | 72 + vendor/image-size/dist/types/psd.d.ts | 2 + vendor/image-size/dist/types/psd.js | 11 + vendor/image-size/dist/types/svg.d.ts | 2 + vendor/image-size/dist/types/svg.js | 90 + vendor/image-size/dist/types/tga.d.ts | 2 + vendor/image-size/dist/types/tga.js | 15 + vendor/image-size/dist/types/tiff.d.ts | 2 + vendor/image-size/dist/types/tiff.js | 95 + vendor/image-size/dist/types/utils.d.ts | 15 + vendor/image-size/dist/types/utils.js | 75 + vendor/image-size/dist/types/webp.d.ts | 2 + vendor/image-size/dist/types/webp.js | 60 + vendor/image-size/dist/utils/bit-reader.d.ts | 10 + vendor/image-size/dist/utils/bit-reader.js | 44 + vendor/image-size/package.json | 71 + 161 files changed, 9532 insertions(+), 927 deletions(-) create mode 100644 .superpowers/sdd/2026-08-10-hangboarding-import-and-ui/task-3-report.md create mode 100644 docs/hang-ten.md create mode 100644 docs/superpowers/plans/2026-08-07-hang-ten-apple-health-import.md create mode 100644 docs/superpowers/plans/2026-08-10-hangboarding-import-and-ui.md create mode 100644 docs/superpowers/specs/2026-08-07-hang-ten-apple-health-import-design.md create mode 100644 docs/superpowers/specs/2026-08-10-hangboarding-ui-design.md create mode 100644 drizzle/0072_add_hangboard_activity_type.sql create mode 100644 packages/mobile/components/HangboardingDetail.stories.tsx create mode 100644 packages/mobile/components/HangboardingDetail.test.tsx create mode 100644 packages/mobile/components/HangboardingDetail.tsx create mode 100644 packages/mobile/components/HangboardingSummary.stories.tsx create mode 100644 packages/mobile/components/HangboardingSummary.test.tsx create mode 100644 packages/mobile/components/HangboardingSummary.tsx create mode 100644 packages/server/src/repositories/hangboarding-repository.integration.test.ts create mode 100644 packages/server/src/repositories/hangboarding-repository.test.ts create mode 100644 packages/server/src/repositories/hangboarding-repository.ts create mode 100644 packages/web/src/components/HangboardingDetail.stories.tsx create mode 100644 packages/web/src/components/HangboardingDetail.test.tsx create mode 100644 packages/web/src/components/HangboardingDetail.tsx create mode 100644 packages/web/src/components/HangboardingSummary.stories.tsx create mode 100644 packages/web/src/components/HangboardingSummary.test.tsx create mode 100644 packages/web/src/components/HangboardingSummary.tsx delete mode 100644 paseo.json create mode 100644 src/db/hangboard-activity-type-migration.integration.test.ts create mode 100644 src/providers/apple-health/hang-ten-intervals.test.ts create mode 100644 src/providers/apple-health/hang-ten-intervals.ts create mode 100644 src/providers/apple-health/parsing-extra.test.ts create mode 100644 vendor/image-size/LICENSE create mode 100644 vendor/image-size/Readme.md create mode 100755 vendor/image-size/bin/image-size.js create mode 100644 vendor/image-size/dist/detector.d.ts create mode 100644 vendor/image-size/dist/detector.js create mode 100644 vendor/image-size/dist/index.d.ts create mode 100644 vendor/image-size/dist/index.js create mode 100644 vendor/image-size/dist/types/bmp.d.ts create mode 100644 vendor/image-size/dist/types/bmp.js create mode 100644 vendor/image-size/dist/types/cur.d.ts create mode 100644 vendor/image-size/dist/types/cur.js create mode 100644 vendor/image-size/dist/types/dds.d.ts create mode 100644 vendor/image-size/dist/types/dds.js create mode 100644 vendor/image-size/dist/types/gif.d.ts create mode 100644 vendor/image-size/dist/types/gif.js create mode 100644 vendor/image-size/dist/types/heif.d.ts create mode 100644 vendor/image-size/dist/types/heif.js create mode 100644 vendor/image-size/dist/types/icns.d.ts create mode 100644 vendor/image-size/dist/types/icns.js create mode 100644 vendor/image-size/dist/types/ico.d.ts create mode 100644 vendor/image-size/dist/types/ico.js create mode 100644 vendor/image-size/dist/types/index.d.ts create mode 100644 vendor/image-size/dist/types/index.js create mode 100644 vendor/image-size/dist/types/interface.d.ts create mode 100644 vendor/image-size/dist/types/interface.js create mode 100644 vendor/image-size/dist/types/j2c.d.ts create mode 100644 vendor/image-size/dist/types/j2c.js create mode 100644 vendor/image-size/dist/types/jp2.d.ts create mode 100644 vendor/image-size/dist/types/jp2.js create mode 100644 vendor/image-size/dist/types/jpg.d.ts create mode 100644 vendor/image-size/dist/types/jpg.js create mode 100644 vendor/image-size/dist/types/jxl-stream.d.ts create mode 100644 vendor/image-size/dist/types/jxl-stream.js create mode 100644 vendor/image-size/dist/types/jxl.d.ts create mode 100644 vendor/image-size/dist/types/jxl.js create mode 100644 vendor/image-size/dist/types/ktx.d.ts create mode 100644 vendor/image-size/dist/types/ktx.js create mode 100644 vendor/image-size/dist/types/png.d.ts create mode 100644 vendor/image-size/dist/types/png.js create mode 100644 vendor/image-size/dist/types/pnm.d.ts create mode 100644 vendor/image-size/dist/types/pnm.js create mode 100644 vendor/image-size/dist/types/psd.d.ts create mode 100644 vendor/image-size/dist/types/psd.js create mode 100644 vendor/image-size/dist/types/svg.d.ts create mode 100644 vendor/image-size/dist/types/svg.js create mode 100644 vendor/image-size/dist/types/tga.d.ts create mode 100644 vendor/image-size/dist/types/tga.js create mode 100644 vendor/image-size/dist/types/tiff.d.ts create mode 100644 vendor/image-size/dist/types/tiff.js create mode 100644 vendor/image-size/dist/types/utils.d.ts create mode 100644 vendor/image-size/dist/types/utils.js create mode 100644 vendor/image-size/dist/types/webp.d.ts create mode 100644 vendor/image-size/dist/types/webp.js create mode 100644 vendor/image-size/dist/utils/bit-reader.d.ts create mode 100644 vendor/image-size/dist/utils/bit-reader.js create mode 100644 vendor/image-size/package.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fe7dad6954..020b23b0c4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,9 @@ updates: interval: "weekly" cooldown: default-days: 7 + ignore: + - dependency-name: "react-native-svg" + versions: [">=15.15.5"] - package-ecosystem: "uv" directories: - "/analytics" @@ -23,9 +26,16 @@ updates: cooldown: default-days: 7 - package-ecosystem: "docker" - directories: - - "/" - - "/packages/ml" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + ignore: + - dependency-name: "python" + versions: [">=3.14"] + - package-ecosystem: "docker" + directory: "/packages/ml" schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/build-docker-ml.yml b/.github/workflows/build-docker-ml.yml index e128da3b18..b409c949dc 100644 --- a/.github/workflows/build-docker-ml.yml +++ b/.github/workflows/build-docker-ml.yml @@ -21,7 +21,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index d40fdaea13..596a8bca16 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -21,7 +21,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -85,7 +85,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aa26b35eab..5cb900601f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,10 +25,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deploy-web-stack.yml b/.github/workflows/deploy-web-stack.yml index 4eaf25b1b7..17186676aa 100644 --- a/.github/workflows/deploy-web-stack.yml +++ b/.github/workflows/deploy-web-stack.yml @@ -335,7 +335,7 @@ jobs: docker stack config $STACK_FILE_FLAGS -c deploy/stack.cdc-quiesce.yml >/dev/null - name: Login to GHCR - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1939da7f40..3734c7ce2a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,7 +70,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -132,7 +132,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index ec4000808a..7de69d58a6 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -24,7 +24,7 @@ jobs: - name: Run community rules (informational) if: always() run: semgrep scan --config auto --sarif --output semgrep.sarif - - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 if: always() with: sarif_file: semgrep.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef11c8f35f..84d4d74681 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -192,7 +192,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -700,7 +700,7 @@ jobs: run: | mkdir -p "$VCPKG_DOWNLOADS" git clone https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" - git -C "$VCPKG_ROOT" checkout ec62869cdd9f80413abb5e4c1d8b68688df932f4 + git -C "$VCPKG_ROOT" checkout 9e593bb18ea69cc5095e012465dcd675a822ed0d "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics - name: Build and test FIT decoder working-directory: native/fit-decoder @@ -919,7 +919,7 @@ jobs: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.superpowers/sdd/2026-08-10-hangboarding-import-and-ui/task-3-report.md b/.superpowers/sdd/2026-08-10-hangboarding-import-and-ui/task-3-report.md new file mode 100644 index 0000000000..5946ef1011 --- /dev/null +++ b/.superpowers/sdd/2026-08-10-hangboarding-import-and-ui/task-3-report.md @@ -0,0 +1,120 @@ +# Task 3 Report: Persist Hang Ten workout intervals + +## Status + +Implemented and committed the Hang Ten persistence layer. The implementation +uses the finalized historical behavior from commits `cdf418e6f`, `b9eaa8cd2`, +`e9a193e4a`, and `c4a653992` for interval construction, activity upserts, +reimport handling, and malformed metadata reporting. + +## Files + +Changed exactly the files named by the Task 3 brief: + +- `src/providers/apple-health/hang-ten-intervals.ts` +- `src/providers/apple-health/hang-ten-intervals.test.ts` +- `src/providers/apple-health/db-insertion.ts` +- `src/providers/apple-health/db-insertion.test.ts` +- `src/providers/apple-health/db-insertion.integration.test.ts` +- `src/providers/apple-health/import.ts` +- `src/providers/apple-health/import.test.ts` +- `src/providers/apple-health/import.integration.test.ts` +- `src/providers/apple-health/test-helpers.ts` + +The pre-existing untracked `paseo.json` was preserved and not staged. + +## Implemented behavior + +- Added Hang Ten interval labels for work, hold-size/type, and rest segments. +- Built ordered `work`/`rest` interval rows with conservative end-time + derivation after unknown durations. +- Replaced an activity's intervals atomically in one SQL statement, deleting + stale intervals before inserting the replacement set. +- Deduplicated workouts by `workoutExternalId`. +- Persisted workout duration, distance, and heart-rate metadata in the raw + payload, including typed `raw.hangTen` metadata. +- Persisted Hang Ten activities as canonical `hangboard` activities named by + their plan and sourced from `Hang Ten`. +- Updated Hang Ten names on reimport while preserving ordinary workout names. +- Preserved malformed segment metadata on the activity and reported a specific, + non-fatal sync error containing the workout external ID. +- Did not add provider-estimated calorie or expenditure data. + +## Tests and exact outcomes + +### TDD RED + +Command: + +```text +rtk pnpm vitest src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/import.test.ts +``` + +Outcome: expected failure. Three test files ran; 200 tests passed and 4 +Hang Ten tests failed because the interval helper and persistence/import +behavior were not yet implemented. The interval test file also failed to load +because `hang-ten-intervals.ts` did not exist. + +### Focused GREEN validation + +Command: + +```text +rtk pnpm vitest src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/import.test.ts +``` + +Outcome: passed. 3 test files, 203 tests passed. + +### TypeScript + +Command: + +```text +rtk pnpm typecheck +``` + +Outcome: passed with `TypeScript: No errors found`. + +### Scoped formatting and lint + +Command: + +```text +rtk pnpm exec biome check src/providers/apple-health/hang-ten-intervals.ts src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/db-insertion.integration.test.ts src/providers/apple-health/import.ts src/providers/apple-health/import.test.ts src/providers/apple-health/import.integration.test.ts src/providers/apple-health/test-helpers.ts +``` + +Outcome: passed. All 9 Task 3 files checked with no fixes required. + +The full `rtk pnpm lint` command passed its exact-version, Biome, suppression, +workflow-download, migration-policy, mobile telemetry, web story, review +scenario, and mobile route checks, then stopped in the ClickHouse SQL lint +phase because the local ClickHouse service was unavailable at +`127.0.0.1:65384`. + +### Database integration + +Command: + +```text +rtk pnpm test:integration -- src/providers/apple-health/db-insertion.integration.test.ts src/providers/apple-health/import.integration.test.ts +``` + +Outcome: not executed. The first attempt could not connect to the Docker +daemon. After starting Docker Desktop and confirming `docker info` reported a +healthy server, the retry stopped before test execution because Docker could +not create the workspace network: `all predefined address pools have been fully +subnetted`. Existing unrelated workspace networks were left untouched. + +## Commits + +- `b8ac48158e4c58cefe373ce0a906764b110b8e13` — `feat: persist Hang Ten workout intervals` + +## Concerns + +- The two requested Postgres integration suites remain unverified locally due + to Docker network address-pool exhaustion before Compose startup. +- Full repository lint remains incomplete only at the ClickHouse SQL stage + because its service was unavailable; all preceding lint stages and the + scoped Biome check passed. +- CI or a host with available Compose network capacity should run the exact + integration command before merge. diff --git a/Dockerfile b/Dockerfile index 3f44f13fc8..2001ab8c17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,11 +19,12 @@ RUN apk add --no-cache build-base && \ FROM alpine:3.24 AS fit-decoder-build ENV VCPKG_ROOT=/opt/vcpkg ENV VCPKG_FORCE_SYSTEM_BINARIES=1 -ARG VCPKG_COMMIT=ec62869cdd9f80413abb5e4c1d8b68688df932f4 +ARG VCPKG_COMMIT=9e593bb18ea69cc5095e012465dcd675a822ed0d RUN apk add --no-cache \ bash \ build-base \ - cmake \ + --repository https://dl-cdn.alpinelinux.org/alpine/edge/main \ + cmake=4.3.4-r0 \ curl \ git \ linux-headers \ diff --git a/cspell.json b/cspell.json index 2da8f2d420..33448113b3 100644 --- a/cspell.json +++ b/cspell.json @@ -66,8 +66,18 @@ "peerflow", "pgcrypto", "fkey", + "hangboard", + "Hangboard", + "hangten", + "hangboarding", + "Hangboarding", "hashtext", "hashtextextended", + "enumlabel", + "enumtypid", + "enumsortorder", + "typnamespace", + "typname", "xact", "savepoint", "btrim", @@ -117,6 +127,8 @@ "codecov", "wahoo", "wahooligan", + "metolius", + "Metolius", "Tronadora", "Kaya", "KAYA", diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index ea7e112ebd..fe94f7698a 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -51,7 +51,7 @@ services: retries: 15 redpanda: - image: mirror.gcr.io/redpandadata/redpanda:v26.1.13 + image: mirror.gcr.io/redpandadata/redpanda:v26.2.1 command: - redpanda - start diff --git a/docker-compose.peerdb.yml b/docker-compose.peerdb.yml index 4262e8c994..5d3d93626e 100644 --- a/docker-compose.peerdb.yml +++ b/docker-compose.peerdb.yml @@ -155,7 +155,7 @@ services: condition: service_started peerdb-flow-worker: - image: ghcr.io/peerdb-io/flow-worker:stable-v0.37.1 + image: ghcr.io/peerdb-io/flow-worker:stable-v0.37.3 environment: *peerdb-flow-environment restart: unless-stopped depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 8689930904..f4b37616e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,7 +72,7 @@ services: retries: 5 redpanda: - image: redpandadata/redpanda:v26.1.13 + image: redpandadata/redpanda:v26.2.1 restart: unless-stopped command: - redpanda diff --git a/docs/hang-ten.md b/docs/hang-ten.md new file mode 100644 index 0000000000..c10a19b8da --- /dev/null +++ b/docs/hang-ten.md @@ -0,0 +1,34 @@ +# Hang Ten Apple Health Import + +Hang Ten is integrated through Apple Health exports rather than a direct cloud +API. The Hang Ten app writes completed workouts to HealthKit as functional +strength training and adds workout metadata with `HKMetadataKeyWorkoutBrandName` +plus `HangTen.*` keys for the plan, session, board, and serialized activity +segments. The implementation source is +[`HealthKitService.swift`](https://github.com/Asherlc/hang-ten/blob/30ec9e8188b33048c948c745449ad67918206b88/HangTen/Models/HealthKitService.swift); +Apple documents the HealthKit workout type and brand metadata key as +[`HKWorkoutActivityType.functionalStrengthTraining`](https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype/functionalstrengthtraining) +and +[`HKMetadataKeyWorkoutBrandName`](https://developer.apple.com/documentation/healthkit/hkmetadatakeyworkoutbrandname). + +Dofek keeps `provider_id = apple_health` for these rows. It recognizes Hang Ten +only when an Apple Health workout export has the functional strength workout +type, a Hang Ten workout brand, and a non-empty `HangTen.PlanName`. Recognized +workouts are stored as canonical `hangboard` activities with the Hang Ten +metadata retained in `activity.raw.hangTen`. + +The current metadata keys consumed by Dofek are: + +- `HKMetadataKeyWorkoutBrandName` +- `HangTen.PlanName` +- `HangTen.SessionID` +- `HangTen.BoardID` +- `HangTen.BoardName` +- `HangTen.ActivitySegments` + +`HangTen.ActivitySegments` is JSON shaped like `{ "version": 1, "segments": [...] }`. +The segment schema comes from Hang Ten's `WorkoutActivityMetadata` and +`RecordedActivitySegment` types in +[`WorkoutActivityRecording.swift`](https://github.com/Asherlc/hang-ten/blob/30ec9e8188b33048c948c745449ad67918206b88/HangTen/Models/WorkoutActivityRecording.swift). +Malformed segment JSON is reported as a non-fatal import error while the +workout row is still imported for provenance. diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index 41874d2f30..fdf0a7732c 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -64,6 +64,34 @@ them, and the durability work they suggest. - **Remaining risk / follow-up:** Confirm the two queued native jobs complete; no code-level CI failure remains in the current run. +## 2026-08-10: Hangboarding pull-request CI failures + +- **Status:** Root causes fixed and pushed; the [replacement CI run](https://github.com/Asherlc/dofek/actions/runs/31431192266) + completed with 105 checks passed and none failed. +- **Symptoms / user impact:** PR #2471 was blocked by SQLFluff, spell check, + mutation testing, an Apple Health integration test, and web typecheck. +- **Evidence:** The initial [failed CI run](https://github.com/Asherlc/dofek/actions/runs/31426584106) + reported an indented migration statement, missing Hangboarding and PostgreSQL + catalog dictionary words, mutation score 61.82 below the 75 threshold, a + PostgreSQL `23514` failure while adding a table-wide test constraint, and two + web TypeScript errors. +- **Root causes:** The migration indentation violated SQLFluff; the dictionary + did not contain the new domain terms; repository branches lacked unit + coverage for several Hangboarding paths; the integration test's constraint + rejected an existing `Step 2: Work` row; and the web components did not + preserve nullable narrowing or accept tRPC error objects. +- **Fix:** Corrected the migration and dictionary, added focused repository + tests, scoped the failure-injection constraint with PostgreSQL's `NOT VALID` + behavior ([official documentation](https://www.postgresql.org/docs/current/sql-altertable.html)), + and corrected the web component types and narrowing. Commits + [`5443228`](https://github.com/Asherlc/dofek/commit/54432285e68bac09b2a318e0f51dfb2fe2375ff9) + and [`ae16be3`](https://github.com/Asherlc/dofek/commit/ae16be3efe234462de83dda5e507e685de21220e) + contain the fixes. +- **Validation:** Local focused tests, the Apple Health integration file, + TypeScript, CSpell, SQLFluff, Biome, and mutation testing passed; the + replacement GitHub run passed all checks. +- **Remaining risk / follow-up:** None identified for this CI failure. + ## 2026-08-07 — Wahoo OAuth callback served as `Not Found` - **Status:** Root cause identified; the PWA update fix is implemented in this @@ -23288,3 +23316,21 @@ Drizzle schema and runtime Zod schemas. Findings and remediations: - **Remaining risk / follow-up:** Remove the two `image-size` audit ignores as soon as upstream publishes a patched release or Expo/Metro removes the vulnerable path; rerun the hosted dependency-audit job after this PR commit. +## 2026-08-08 — Dependabot PRs exposed stale CI baselines and incompatible runtime upgrades + +- **Status:** Resolved in PR [#2448](https://github.com/Asherlc/dofek/pull/2448). PR [#2423](https://github.com/Asherlc/dofek/pull/2423) was refreshed onto current `main` and merged after hosted run [31266778240](https://github.com/Asherlc/dofek/actions/runs/31266778240) passed; incompatible PRs [#2421](https://github.com/Asherlc/dofek/pull/2421) and [#2429](https://github.com/Asherlc/dofek/pull/2429) were closed. +- **Symptoms / impact:** #2421 initially failed [Image Vulnerability Scan](https://github.com/Asherlc/dofek/actions/runs/30961542865/job/92166470552) and [E2E Tests (Web)](https://github.com/Asherlc/dofek/actions/runs/30961542865/job/92166470826). #2423 failed [Integration Tests (3/4)](https://github.com/Asherlc/dofek/actions/runs/30961577671/job/92167161546). #2429 failed [Metro Bundle](https://github.com/Asherlc/dofek/actions/runs/31192266739/job/92912059957). No production impact was observed. +- **Evidence / root cause:** #2421's initial fatal line was `COPY --from=dbt-tools /usr/local/bin/python3.13 ...: not found`: the PR selected the [official Python 3.14.6 Alpine image](https://hub.docker.com/_/python/) but retained Python 3.13 runtime paths in the server stage. After those paths were aligned, the E2E job [93126662595](https://github.com/Asherlc/dofek/actions/runs/31266962159/job/93126662595) failed at analytics startup with `mashumaro.exceptions.UnserializableField: Field "schema" of type Optional[str] in JSONObjectSchema is not serializable`; stable `dbt-core==1.11.12` resolves `mashumaro==3.14`, which is incompatible with Python 3.14; see [dbt-core issue #12098](https://github.com/dbt-labs/dbt-core/issues/12098) and the [dbt-core 1.11.12 metadata](https://pypi.org/project/dbt-core/1.11.12/). #2423's first fatal assertion was at `src/account-erasure/restore-reconciliation.integration.test.ts:309`, where a concurrent reconciler returned `recoveredRequestIds: []`; the branch was based before the existing [concurrency stabilization](https://github.com/Asherlc/dofek/commit/c1f43cb3378d148e3a56e7cbabf47e99147d499f). #2429's first fatal line was `react-native-svg@15.15.5 - expected version: 15.15.4`; [Expo's SDK 57 SVG guidance](https://docs.expo.dev/versions/v57.0.0/sdk/svg/) and [version-validation documentation](https://docs.expo.dev/more/expo-cli/#version-validation) support keeping the check enabled. +- **Fix / mitigation:** Restored the root `dbt-tools` image and copied interpreter/library paths to Python 3.13, and added a root-Dockerfile-only Dependabot ignore for Python versions `>=3.14`; the ML Docker update remains independently managed. Added a narrow Dependabot ignore for `react-native-svg` versions `>=15.15.5`, while retaining the SDK-managed `15.15.4` pin and compatibility gate. Refreshed #2423 onto current `main`; no retry, timeout, test skip, or compatibility-check bypass was added. +- **Validation:** #2448's refreshed hosted run [31268664654](https://github.com/Asherlc/dofek/actions/runs/31268664654) completed successfully: all executed lint, unit, integration, coverage, typecheck, security, and gate jobs passed. The explicit all-areas workflow-dispatch run [31269405954](https://github.com/Asherlc/dofek/actions/runs/31269405954) then passed the Docker build, [image scan](https://github.com/Asherlc/dofek/actions/runs/31269405954/job/93132862709), [web E2E](https://github.com/Asherlc/dofek/actions/runs/31269405954/job/93132862713), all integration shards, coverage, Test Gate, and CI Gate on the supported Python 3.13 image. +- **Remaining risk / follow-up:** Revisit the Python ignore when stable dbt releases support Python 3.14, and revisit the `react-native-svg` ignore during the next Expo SDK upgrade. + +## 2026-08-08 — PR 2447 CI migration failed on a removed activity enum + +- **Status:** Fixed in the workspace; hosted CI needs a fresh run from the + updated commit. No production impact was observed. +- **Symptoms / impact:** [PR CI run 31242532102](https://github.com/Asherlc/dofek/actions/runs/31242532102) failed all four integration shards and the web E2E migration step, blocking PR #2447. +- **Evidence / root cause:** The first fatal database line in [integration shard 1](https://github.com/Asherlc/dofek/actions/runs/31242532102/job/93066014694) and the E2E migration log was `type "fitness.activity_type" does not exist`. Migration [`0068_canonical_activity_types.sql`](../drizzle/0068_canonical_activity_types.sql) drops that legacy enum, while [`0071_add_hangboard_activity_type.sql`](../drizzle/0071_add_hangboard_activity_type.sql) still attempted to alter it. PostgreSQL applies enum-value changes through `ALTER TYPE` ([official documentation](https://www.postgresql.org/docs/current/sql-altertype.html)). +- **Fix / mitigation:** Removed the obsolete legacy-enum statement and added a real Postgres integration regression that applies migrations 0068 and 0071 together, verifies `hangboard` on `canonical_activity_type`, and verifies the legacy enum remains absent. No migration skip, retry, timeout, or failure suppression was added. +- **Validation:** The regression test passes 1/1, the full seed/migration integration test passes 2/2, migration policy passes, and TypeScript typecheck passes locally. Full analytics SQL lint was not runnable because this workspace could not start isolated Compose services after Docker reported `all predefined address pools have been fully subnetted`. +- **Remaining risk / follow-up:** Push the workspace changes and confirm the hosted PR CI rerun passes the integration and E2E migration jobs; clean only disposable stale Docker networks if local analytics lint must be rerun. diff --git a/docs/schema.dbml b/docs/schema.dbml index 4bb0ccc897..adc82db4a1 100644 --- a/docs/schema.dbml +++ b/docs/schema.dbml @@ -92,6 +92,7 @@ enum canonical_activity_type { golf disc_golf climbing + hangboard dance triathlon multisport diff --git a/docs/superpowers/plans/2026-08-07-hang-ten-apple-health-import.md b/docs/superpowers/plans/2026-08-07-hang-ten-apple-health-import.md new file mode 100644 index 0000000000..cc04dbcf7f --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-hang-ten-apple-health-import.md @@ -0,0 +1,811 @@ +# Hang Ten Apple Health Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Import Hang Ten Apple Health workouts as canonical `hangboard` activities with preserved Hang Ten metadata and ordered activity intervals. + +**Architecture:** Extend the existing Apple Health import pipeline instead of adding a separate provider. The streaming parser attaches workout metadata, the workout model normalizes Hang Ten metadata into typed fields, and the database insertion layer writes one activity plus idempotent intervals. + +**Tech Stack:** TypeScript, Drizzle ORM, Postgres enum migrations, Vitest unit tests, Vitest integration tests with the existing test database helper. + +## Global Constraints + +- Keep `provider_id = apple_health`; represent Hang Ten through `sourceName` and raw metadata. +- Add `hangboard` as a first-class canonical activity type in the shared training package and database enum. +- Recognize Hang Ten only when the Apple Health export uses `HKWorkoutActivityTypeFunctionalStrengthTraining`, the HealthKit workout brand metadata key contains `Hang Ten`, and `HangTen.PlanName` is present after trimming whitespace. Apple documents the HealthKit functional strength workout type and workout brand metadata key in `HKWorkoutActivityType.functionalStrengthTraining` and `HKMetadataKeyWorkoutBrandName`: https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype/functionalstrengthtraining and https://developer.apple.com/documentation/healthkit/hkmetadatakeyworkoutbrandname. +- Store Hang Ten session ID, plan name, board ID, board name, raw segment JSON, and parsed segments in `activity.raw`. +- Insert one `activity_interval` per parsed segment and replace existing intervals on reimport. +- Malformed Hang Ten segment JSON must report a sync error while still importing the workout row. +- Do not add a direct Hang Ten API, direct Hang Ten provider, cloud account sync, or new hangboard-specific database columns. +- Follow TDD: write failing tests before implementation code. + +--- + +## File Structure + +- Modify `packages/training/src/training.ts`: add `hangboard` to canonical activity types and labels. +- Modify `packages/training/src/training.test.ts`: prove `hangboard` is canonical and labels as `Hangboard`. +- Modify `src/db/schema/enums.ts`: add `hangboard` to the Drizzle canonical activity type enum. +- Create `drizzle/0071_add_hangboard_activity_type.sql`: add the canonical enum value with `ALTER TYPE ... ADD VALUE IF NOT EXISTS 'hangboard';`. +- Modify `src/providers/apple-health/workouts.ts`: add metadata and Hang Ten segment parsing helpers on `HealthWorkout`. +- Modify `src/providers/apple-health/parsing.test.ts` and `src/providers/apple-health/parsing-extra.test.ts`: cover Hang Ten detection and metadata parsing. +- Modify `src/providers/apple-health/streaming.ts`: collect nested `MetadataEntry` elements for open workouts. +- Modify `src/providers/apple-health/streaming.test.ts`: prove metadata entries are attached to streamed workouts. +- Modify `src/providers/apple-health/db-insertion.ts`: write Hang Ten activity fields, raw payload, and intervals. +- Modify `src/providers/apple-health/db-insertion.test.ts`: unit-test raw payload, external IDs, interval rows, and malformed segment handling. +- Modify `src/providers/apple-health/db-insertion.integration.test.ts`: verify real DB interval replacement. +- Modify `src/providers/apple-health/import.ts`: report malformed Hang Ten segment metadata as non-fatal sync errors. +- Modify `src/providers/apple-health/import.test.ts`: unit-test non-fatal Hang Ten segment metadata sync errors. +- Modify `src/providers/apple-health/import.integration.test.ts`: verify a minimal export creates a `hangboard` activity with intervals. + +--- + +### Task 1: Add Canonical `hangboard` Activity Type + +**Files:** +- Modify: `packages/training/src/training.ts` +- Modify: `packages/training/src/training.test.ts` +- Modify: `src/db/schema.ts` +- Create: `drizzle/0071_add_hangboard_activity_type.sql` + +**Interfaces:** +- Produces: canonical activity type literal `"hangboard"` usable anywhere `CanonicalActivityType` is accepted. +- Produces: database enum value `fitness.canonical_activity_type = 'hangboard'`. + +- [ ] **Step 1: Write the failing shared training tests** + +Add assertions in `packages/training/src/training.test.ts`: + +```ts +expect(CANONICAL_ACTIVITY_TYPES).toContain("hangboard"); +expect(formatActivityTypeLabel("hangboard")).toBe("Hangboard"); +``` + +- [ ] **Step 2: Run training tests to verify failure** + +Run: `rtk pnpm vitest packages/training/src/training.test.ts` + +Expected: FAIL because `CANONICAL_ACTIVITY_TYPES` does not contain `hangboard`. + +- [ ] **Step 3: Add canonical type and label** + +In `packages/training/src/training.ts`, add `"hangboard"` near the other climbing/strength-adjacent types in `CANONICAL_ACTIVITY_TYPES`. Add this entry to `ACTIVITY_TYPE_LABELS`: + +```ts +hangboard: "Hangboard", +``` + +- [ ] **Step 4: Add the Drizzle enum value** + +In `src/db/schema/enums.ts`, add `"hangboard"` to `canonicalActivityTypeEnum` near `"climbing"` and `"rock_climbing"`. + +Create `drizzle/0071_add_hangboard_activity_type.sql`: + +```sql +ALTER TYPE fitness.canonical_activity_type ADD VALUE IF NOT EXISTS 'hangboard' AFTER 'climbing'; +``` + +- [ ] **Step 5: Run focused tests** + +Run: `rtk pnpm vitest packages/training/src/training.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Run migration locally** + +Run: `rtk pnpm migrate` + +Expected: migration succeeds. + +- [ ] **Step 7: Commit** + +```bash +rtk git add packages/training/src/training.ts packages/training/src/training.test.ts src/db/schema.ts drizzle/0071_add_hangboard_activity_type.sql drizzle/meta/_journal.json +rtk git commit -m "feat: add hangboard activity type" +rtk git push +``` + +--- + +### Task 2: Parse Hang Ten Workout Metadata + +**Files:** +- Modify: `src/providers/apple-health/workouts.ts` +- Modify: `src/providers/apple-health/parsing.test.ts` +- Modify: `src/providers/apple-health/parsing-extra.test.ts` +- Modify: `src/providers/apple-health/streaming.ts` +- Modify: `src/providers/apple-health/streaming.test.ts` + +**Interfaces:** +- Consumes: `CanonicalActivityType` includes `"hangboard"` from Task 1. +- Produces: `HealthWorkout.metadata?: Record`. +- Produces: `HealthWorkout.hangTen?: HangTenWorkoutMetadata`. +- Produces: `applyWorkoutMetadata(workout: HealthWorkout, metadata: Record): HealthWorkout`. +- Produces: + +```ts +export interface HangTenActivitySegment { + stepID: string; + stepNumber: number; + kind: "work" | "rest"; + holdIDs: string[]; + holdType?: string; + sizeMillimeters?: number; + durationSeconds?: number; +} + +export interface HangTenWorkoutMetadata { + sessionId?: string; + planName: string; + boardId?: string; + boardName?: string; + rawActivitySegments?: string; + activitySegments?: HangTenActivitySegment[]; + activitySegmentsError?: string; +} +``` + +- [ ] **Step 1: Write failing workout parser tests** + +In `src/providers/apple-health/parsing.test.ts`, add a test that calls `parseWorkout` with a functional strength workout and metadata: + +```ts +const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + duration: "10", + durationUnit: "min", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "7/3 Repeaters", + "HangTen.SessionID": "11111111-1111-4111-8111-111111111111", + "HangTen.BoardID": "metolius-compact-ii", + "HangTen.BoardName": "Metolius Compact II", + "HangTen.ActivitySegments": + '{"segments":[{"stepID":"step-1","stepNumber":1,"kind":"work","holdIDs":["edge-19"],"holdType":"edge","sizeMillimeters":19,"durationSeconds":7}],"version":1}', + }, +); + +expect(result.activityType).toBe("hangboard"); +expect(result.sourceName).toBe("Hang Ten"); +expect(result.hangTen).toMatchObject({ + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", +}); +expect(result.hangTen?.activitySegments).toEqual([ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, +]); +``` + +In `src/providers/apple-health/parsing-extra.test.ts`, add a malformed JSON case: + +```ts +const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + duration: "10", + durationUnit: "min", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": "{not json", + }, +); + +expect(result.activityType).toBe("hangboard"); +expect(result.hangTen?.rawActivitySegments).toBe("{not json"); +expect(result.hangTen?.activitySegments).toBeUndefined(); +expect(result.hangTen?.activitySegmentsError).toContain("Invalid Hang Ten activity segments JSON"); +``` + +- [ ] **Step 2: Run parser tests to verify failure** + +Run: `rtk pnpm vitest src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts` + +Expected: FAIL because `parseWorkout` does not accept metadata or produce `hangTen`. + +- [ ] **Step 3: Implement typed metadata parsing** + +In `src/providers/apple-health/workouts.ts`, import Zod and update the function signature: + +```ts +import { z } from "zod"; + +export function parseWorkout( + attrs: Record, + metadata: Record = {}, +): HealthWorkout +``` + +Add the interfaces from this task. Add helpers: + +```ts +function trimmedMetadataValue(metadata: Record, key: string): string | undefined { + const value = metadata[key]?.trim(); + return value ? value : undefined; +} + +const hangTenActivityMetadataSchema = z.object({ + version: z.number().optional(), + segments: z.array( + z.object({ + stepID: z.string(), + stepNumber: z.number(), + kind: z.enum(["work", "rest"]), + holdIDs: z.array(z.string()), + holdType: z.string().optional(), + sizeMillimeters: z.number().optional(), + durationSeconds: z.number().optional(), + }), + ), +}); + +function parseHangTenActivitySegments(raw: string): { + segments?: HangTenActivitySegment[]; + error?: string; +} { + try { + const parsed: unknown = JSON.parse(raw); + const result = hangTenActivityMetadataSchema.safeParse(parsed); + if (!result.success) { + return { error: "Invalid Hang Ten activity segments JSON: segment metadata has invalid fields" }; + } + return { segments: result.data.segments }; + } catch { + return { error: "Invalid Hang Ten activity segments JSON: could not parse JSON" }; + } +} +``` + +When metadata identifies Hang Ten, return `activityType: "hangboard"`, `sourceName: "Hang Ten"`, and `hangTen`. Export: + +```ts +export function applyWorkoutMetadata( + workout: HealthWorkout, + metadata: Record, +): HealthWorkout { + return { + ...workout, + metadata, + ...hangTenWorkoutOverrides(workout.activityType, metadata), + }; +} +``` + +`hangTenWorkoutOverrides()` is a private helper that returns `{ activityType: "hangboard", sourceName: "Hang Ten", hangTen }` only for recognized Hang Ten metadata, otherwise `{}`. + +- [ ] **Step 4: Write failing streaming metadata test** + +In `src/providers/apple-health/streaming.test.ts`, add: + +```ts +it("attaches MetadataEntry values to workouts", async () => { + const xml = ` + + + + + + +`; + const path = writeXml("hang-ten-workout.xml", xml); + + const workouts: HealthWorkout[] = []; + await streamHealthExport(path, new Date("2020-01-01"), { + onRecordBatch: async () => {}, + onSleepBatch: async () => {}, + onWorkoutBatch: async (batch) => { + workouts.push(...batch); + }, + }); + + expect(workouts[0]?.activityType).toBe("hangboard"); + expect(workouts[0]?.hangTen?.planName).toBe("7/3 Repeaters"); + expect(workouts[0]?.metadata?.["HangTen.PlanName"]).toBe("7/3 Repeaters"); +}); +``` + +- [ ] **Step 5: Implement streaming metadata collection** + +In `src/providers/apple-health/streaming.ts`, add: + +```ts +let currentWorkoutMetadata: Record = {}; +``` + +When opening `Workout`, reset `currentWorkoutMetadata = {}`. When opening `MetadataEntry` while `currentWorkout` exists: + +```ts +if (node.name === "MetadataEntry" && currentWorkout && attrs.key && attrs.value !== undefined) { + currentWorkoutMetadata[attrs.key] = attrs.value; +} +``` + +Before flushing a workout, call: + +```ts +currentWorkout = applyWorkoutMetadata(currentWorkout, currentWorkoutMetadata); +``` + +Do this before `enrichWorkoutFromStats(currentWorkout, currentWorkoutStats)` so heart-rate and calorie enrichment still applies to the Hang Ten workout row. + +- [ ] **Step 6: Run focused parser and streaming tests** + +Run: `rtk pnpm vitest src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.test.ts` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +rtk git add src/providers/apple-health/workouts.ts src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.ts src/providers/apple-health/streaming.test.ts +rtk git commit -m "feat: parse Hang Ten Apple Health metadata" +rtk git push +``` + +--- + +### Task 3: Insert Hang Ten Raw Payloads and Intervals + +**Files:** +- Modify: `src/providers/apple-health/db-insertion.ts` +- Modify: `src/providers/apple-health/db-insertion.test.ts` +- Modify: `src/providers/apple-health/db-insertion.integration.test.ts` + +**Interfaces:** +- Consumes: `HealthWorkout.hangTen?: HangTenWorkoutMetadata` from Task 2. +- Produces: + +```ts +export function hangTenIntervalLabel(segment: HangTenActivitySegment): string; +export function buildHangTenIntervals( + activityId: string, + workout: HealthWorkout, +): (typeof activityInterval.$inferInsert)[]; +``` + +- [ ] **Step 1: Write failing unit tests for raw rows and external IDs** + +In `src/providers/apple-health/db-insertion.test.ts`, add: + +```ts +it("uses Hang Ten session metadata for hangboard activity rows", async () => { + const start = new Date("2026-08-07T14:00:00Z"); + const { db, capture } = createMockDb([{ id: "act-1" }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: "hangboard", + sourceName: "Hang Ten", + startDate: start, + hangTen: { + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + rawActivitySegments: '{"segments":[],"version":1}', + activitySegments: [], + }, + }), + ]); + + expect(capture.values[0]?.[0]).toMatchObject({ + providerId: "apple_health", + externalId: "ah:workout:11111111-1111-4111-8111-111111111111", + activityType: "hangboard", + name: "7/3 Repeaters", + sourceName: "Hang Ten", + raw: { + durationSeconds: 1800, + hangTen: { + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + rawActivitySegments: '{"segments":[],"version":1}', + activitySegments: [], + }, + }, + }); +}); +``` + +- [ ] **Step 2: Write failing unit tests for interval labels and times** + +Add: + +```ts +it("builds Hang Ten intervals with labels and cumulative times", async () => { + const start = new Date("2026-08-07T14:00:00Z"); + const { db, capture } = createMockDb([{ id: "act-1" }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: "hangboard", + sourceName: "Hang Ten", + startDate: start, + endDate: new Date("2026-08-07T14:01:00Z"), + hangTen: { + planName: "Repeaters", + activitySegments: [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, + { + stepID: "step-1-rest", + stepNumber: 1, + kind: "rest", + holdIDs: [], + durationSeconds: 3, + }, + { + stepID: "step-2", + stepNumber: 2, + kind: "work", + holdIDs: ["jug"], + }, + ], + }, + }), + ]); + + expect(capture.values[1]).toEqual([ + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: start, + endedAt: new Date("2026-08-07T14:00:07Z"), + }), + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 1, + label: "Step 1: Rest", + intervalType: "rest", + startedAt: new Date("2026-08-07T14:00:07Z"), + endedAt: new Date("2026-08-07T14:00:10Z"), + }), + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 2, + label: "Step 2: Work", + intervalType: "work", + startedAt: new Date("2026-08-07T14:00:10Z"), + endedAt: undefined, + }), + ]); +}); +``` + +- [ ] **Step 3: Run unit tests to verify failure** + +Run: `rtk pnpm vitest src/providers/apple-health/db-insertion.test.ts` + +Expected: FAIL because `hangTen` is ignored and no intervals are inserted. + +- [ ] **Step 4: Implement raw payload and external ID helpers** + +In `src/providers/apple-health/db-insertion.ts`, import `activityInterval` and `type HangTenActivitySegment`. + +Add: + +```ts +function workoutExternalId(workout: HealthWorkout): string { + return workout.hangTen?.sessionId + ? `ah:workout:${workout.hangTen.sessionId}` + : `ah:workout:${workout.startDate.toISOString()}`; +} + +function workoutName(workout: HealthWorkout): string { + return workout.hangTen?.planName ?? workout.activityType; +} + +function workoutRawPayload(workout: HealthWorkout): Record { + const raw: Record = { durationSeconds: workout.durationSeconds }; + if (workout.distanceMeters !== undefined) raw.distanceMeters = workout.distanceMeters; + if (workout.avgHeartRate !== undefined) raw.avgHeartRate = workout.avgHeartRate; + if (workout.maxHeartRate !== undefined) raw.maxHeartRate = workout.maxHeartRate; + if (workout.hangTen) raw.hangTen = workout.hangTen; + return raw; +} +``` + +Use these helpers in the activity insert rows and dedup map. + +- [ ] **Step 5: Implement interval helpers and insertion** + +Add: + +```ts +export function hangTenIntervalLabel(segment: HangTenActivitySegment): string { + if (segment.kind === "rest") return `Step ${segment.stepNumber}: Rest`; + if (segment.sizeMillimeters !== undefined && segment.holdType) { + return `Step ${segment.stepNumber}: ${segment.sizeMillimeters} mm ${segment.holdType}`; + } + if (segment.holdType) return `Step ${segment.stepNumber}: ${segment.holdType}`; + return `Step ${segment.stepNumber}: Work`; +} + +export function buildHangTenIntervals( + activityId: string, + workout: HealthWorkout, +): (typeof activityInterval.$inferInsert)[] { + const segments = workout.hangTen?.activitySegments; + if (!segments || segments.length === 0) return []; + const rows: (typeof activityInterval.$inferInsert)[] = []; + let cursor: Date | null = workout.startDate; + for (const [index, segment] of segments.entries()) { + const startedAt = cursor ?? workout.startDate; + const endedAt = + cursor && segment.durationSeconds !== undefined + ? new Date(cursor.getTime() + segment.durationSeconds * 1000) + : undefined; + rows.push({ + activityId, + intervalIndex: index, + label: hangTenIntervalLabel(segment), + intervalType: segment.kind, + startedAt, + endedAt, + }); + cursor = endedAt ?? null; + } + return rows; +} +``` + +After activity inserts return IDs, collect intervals for Hang Ten workouts. Delete existing intervals for each returned Hang Ten activity, then insert new interval rows: + +```ts +await db.delete(activityInterval).where(eq(activityInterval.activityId, activityId)); +``` + +Insert intervals only when `buildHangTenIntervals()` returns rows. + +- [ ] **Step 6: Preserve malformed segment parse errors in raw payload** + +Ensure `workoutRawPayload()` includes: + +```ts +if (workout.hangTen?.activitySegmentsError) { + raw.hangTen = workout.hangTen; +} +``` + +The parse error is stored with the workout row. No interval rows are inserted when `activitySegments` is absent. + +- [ ] **Step 7: Write failing integration test for idempotent interval replacement** + +In `src/providers/apple-health/db-insertion.integration.test.ts`, add a test that imports one Hang Ten workout twice with the same session ID and two segments. After the second import, query `schema.activityInterval` for that activity and assert exactly two rows exist with labels `Step 1: 19 mm edge` and `Step 1: Rest`. + +- [ ] **Step 8: Run integration dependencies and focused tests** + +Run: + +```bash +rtk pnpm test:integration -- src/providers/apple-health/db-insertion.integration.test.ts +rtk pnpm vitest run --project unit src/providers/apple-health/db-insertion.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +rtk git add src/providers/apple-health/db-insertion.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/db-insertion.integration.test.ts +rtk git commit -m "feat: store Hang Ten workout intervals" +rtk git push +``` + +--- + +### Task 4: Verify End-to-End Apple Health Import + +**Files:** +- Modify: `src/providers/apple-health/import.ts` +- Modify: `src/providers/apple-health/import.test.ts` +- Modify: `src/providers/apple-health/import.integration.test.ts` + +**Interfaces:** +- Consumes: `parseWorkout()` Hang Ten metadata behavior from Task 2. +- Consumes: `upsertWorkoutBatch()` Hang Ten insertion behavior from Task 3. +- Produces: `runImport()` appends a non-fatal `SyncError` for each workout with `hangTen.activitySegmentsError`. +- Produces: end-to-end confidence that Apple Health export XML creates a `hangboard` activity and intervals. + +- [ ] **Step 1: Write failing non-fatal sync error unit test** + +In `src/providers/apple-health/import.test.ts`, update the existing `streamHealthExport` mock test coverage for `runImport()` or add a new case that invokes the captured `onWorkoutBatch` handler with: + +```ts +[ + { + activityType: "hangboard", + sourceName: "Hang Ten", + durationSeconds: 600, + startDate: new Date("2026-08-07T14:00:00Z"), + endDate: new Date("2026-08-07T14:10:00Z"), + hangTen: { + planName: "Max Hangs", + rawActivitySegments: "{not json", + activitySegmentsError: "Invalid Hang Ten activity segments JSON: could not parse JSON", + }, + }, +] +``` + +Assert the returned `SyncResult` includes: + +```ts +expect(result.errors).toEqual([ + expect.objectContaining({ + externalId: "ah:workout:2026-08-07T14:00:00.000Z", + message: "Invalid Hang Ten activity segments JSON: could not parse JSON", + }), +]); +``` + +- [ ] **Step 2: Implement non-fatal sync error reporting** + +In `src/providers/apple-health/import.ts`, add a helper: + +```ts +function collectWorkoutImportErrors(workouts: HealthWorkout[]): SyncError[] { + return workouts.flatMap((workout) => { + const message = workout.hangTen?.activitySegmentsError; + if (!message) return []; + return [ + { + message, + externalId: workout.hangTen?.sessionId + ? `ah:workout:${workout.hangTen.sessionId}` + : `ah:workout:${workout.startDate.toISOString()}`, + }, + ]; + }); +} +``` + +Inside the existing `onWorkoutBatch` callback, after `upsertWorkoutBatch()` succeeds, append: + +```ts +errors.push(...collectWorkoutImportErrors(workouts)); +``` + +- [ ] **Step 3: Write failing end-to-end import test** + +In `src/providers/apple-health/import.integration.test.ts`, add an XML fixture containing: + +```xml + + + + + + + + +``` + +After `importAppleHealthFile()`, query `schema.activity` and `schema.activityInterval`. Assert: + +```ts +expect(hangboard?.activityType).toBe("hangboard"); +expect(hangboard?.name).toBe("7/3 Repeaters"); +expect(hangboard?.sourceName).toBe("Hang Ten"); +expect(hangboard?.externalId).toBe("ah:workout:11111111-1111-4111-8111-111111111111"); +expect(hangboard?.raw).toMatchObject({ + hangTen: { + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + }, +}); +expect(intervals.map((interval) => interval.label)).toEqual([ + "Step 1: 19 mm edge", + "Step 1: Rest", +]); +``` + +- [ ] **Step 4: Run end-to-end test to verify failure or pass against previous tasks** + +Run: + +```bash +rtk pnpm compose -- up -d db redis +rtk pnpm compose -- ps db redis +rtk pnpm test:integration -- src/providers/apple-health/import.integration.test.ts +``` + +Expected before implementation: FAIL. Expected after Tasks 1-3: PASS. + +- [ ] **Step 5: Apply exact end-to-end corrections if needed** + +If XML entity decoding leaves escaped JSON in metadata, change the streaming metadata assignment to store SAX's decoded `attrs.value` string directly: + +```ts +currentWorkoutMetadata[attrs.key] = attrs.value; +``` + +If the activity query returns multiple rows, query by the exact Hang Ten external ID: + +```ts +const hangboard = activities.find( + (activityRow) => + activityRow.externalId === "ah:workout:11111111-1111-4111-8111-111111111111", +); +``` + +- [ ] **Step 6: Run all focused Apple Health and training tests** + +Run: + +```bash +rtk pnpm vitest packages/training/src/training.test.ts src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.test.ts src/providers/apple-health/db-insertion.test.ts +rtk pnpm test:integration -- src/providers/apple-health/db-insertion.integration.test.ts src/providers/apple-health/import.integration.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run changed tests and typecheck** + +Run: + +```bash +rtk pnpm test:changed +rtk pnpm tsc --noEmit +rtk pnpm --dir packages/server tsc --noEmit +rtk pnpm --dir packages/web tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +rtk git add src/providers/apple-health/import.ts src/providers/apple-health/import.test.ts src/providers/apple-health/import.integration.test.ts +rtk git commit -m "test: cover Hang Ten Apple Health import" +rtk git push +``` + +--- + +## Final Verification + +- [ ] Run `rtk pnpm lint`. +- [ ] Run `rtk pnpm test:changed`. +- [ ] Run `rtk pnpm tsc --noEmit`. +- [ ] Run `rtk pnpm --dir packages/server tsc --noEmit`. +- [ ] Run `rtk pnpm --dir packages/web tsc --noEmit`. +- [ ] Run `rtk git status --short`. +- [ ] Summarize the root behavior change, validation evidence, and any residual risk. diff --git a/docs/superpowers/plans/2026-08-10-hangboarding-import-and-ui.md b/docs/superpowers/plans/2026-08-10-hangboarding-import-and-ui.md new file mode 100644 index 0000000000..7bfab9d0c0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-hangboarding-import-and-ui.md @@ -0,0 +1,820 @@ +# Hangboarding Import and Training UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the finalized Hang Ten Apple Health importer into this branch and expose Hangboarding metadata, work/rest intervals, activity details, and climbing-page metrics on web and mobile. + +**Architecture:** Extend the existing Apple Health XML pipeline and reuse `fitness.activity_interval`; Hang Ten remains an Apple Health source but is classified as canonical `hangboard`. A focused server repository will expose validated activity-detail metadata and date-range summaries, and both clients will render those server-computed values without calculating metrics. + +**Tech Stack:** TypeScript, Zod, Drizzle/Postgres, tRPC, Vitest, React/Vite, Expo/React Native, existing ECharts and SVG chart components. + +## Global Constraints + +- Reuse the finalized importer behavior from the local `codex/hang-ten-apple-health-import` ref; do not cherry-pick its historical migration number because this branch already uses migration `0071`. +- Keep `provider_id = apple_health`; recognize Hang Ten only for functional-strength workouts branded exactly `Hang Ten` with a non-empty `HangTen.PlanName`. +- Use internal canonical type `hangboard` and user-facing label `Hangboarding`. +- Preserve Hang Ten provenance in `activity.raw.hangTen`; use `fitness.activity_interval` for ordered work/rest segments; do not add duplicate Hang Ten tables or columns. +- Keep all interval timestamps, aggregate metrics, trend values, and missing-data decisions on the server. +- Do not ingest, compute, or display provider-estimated calories, inferred hang load, or invented set/repetition values. +- Implement equivalent behavior in `packages/web` and `packages/mobile`. +- Follow TDD: each production behavior begins with a failing test, and database behavior uses executable integration tests. +- Use `rtk` before every shell command and use `apply_patch` for file edits. + +--- + +## File map + +### Importer and domain files + +- `packages/training/src/activity-types.ts`, `packages/training/src/training.ts`: register the `hangboard` canonical type and `Hangboarding` label. +- `packages/training/src/activity-types.test.ts`, `packages/training/src/training.test.ts`: prove the type and label contract. +- `src/db/schema/enums.ts`, `drizzle/0072_add_hangboard_activity_type.sql`, `drizzle/meta/_journal.json`: add the forward-only current-branch enum migration. +- `src/providers/apple-health/workouts.ts`: parse Hang Ten metadata, typed segments, canonical type, display name inputs, and stable external IDs. +- `src/providers/apple-health/streaming.ts`: collect nested `MetadataEntry` values while a workout is open. +- `src/providers/apple-health/hang-ten-intervals.ts`: build and idempotently replace activity intervals. +- `src/providers/apple-health/db-insertion.ts`: persist Hang Ten raw payloads and intervals. +- `src/providers/apple-health/import.ts`: report malformed segment JSON as a non-fatal import error. +- `src/providers/apple-health/*test.ts`: port the finalized importer tests from the historical ref and add current-branch regression coverage. + +### Server files + +- `packages/server/src/repositories/hangboarding-repository.ts`: read Hangboarding detail metadata and server-computed range summaries. +- `packages/server/src/repositories/hangboarding-repository.test.ts`: unit-test row mapping and summary reduction helpers. +- `packages/server/src/repositories/hangboarding-repository.integration.test.ts`: execute the summary/detail queries against Postgres fixtures. +- `packages/server/src/routers/activity.ts`, `activity.test.ts`, `activity.integration.test.ts`: expose `activity.hangboardDetails`. +- `packages/server/src/routers/climbing.ts`, `climbing.test.ts`, `climbing.integration.test.ts`: expose `climbing.hangboardingSummary`. +- `packages/server/src/contracts/mobile-dashboard-contracts.ts`: validate the mobile training payload's `climbing.hangboarding` block. +- `packages/server/src/services/mobile-training-tab.ts`, `mobile-training-tab.test.ts`: load the same summary for mobile. + +### Web files + +- `packages/web/src/components/HangboardingDetail.tsx`, `.test.tsx`, `.stories.tsx`: render plan/board/session metadata and interval rows. +- `packages/web/src/components/HangboardingSummary.tsx`, `.test.tsx`, `.stories.tsx`: render metric cards and the daily trend. +- `packages/web/src/pages/ActivityDetailPage.tsx`, `.test.tsx`: query and render Hangboarding activity details. +- `packages/web/src/routes/training/climbing.tsx`, `.test.tsx`: query and render Hangboarding summary data. + +### Mobile files + +- `packages/mobile/components/HangboardingDetail.tsx`, `.test.tsx`, `.stories.tsx`: render the activity-detail interval and metadata block. +- `packages/mobile/components/HangboardingSummary.tsx`, `.test.tsx`, `.stories.tsx`: render the compact climbing-card metrics and trend. +- `packages/mobile/app/activity/[id].tsx`, `packages/mobile/app-tests/activity/[id].test.tsx`: query and render Hangboarding detail. +- `packages/mobile/app/(tabs)/strain.tsx`, `packages/mobile/app-tests/(tabs)/strain.test.tsx`: parse and render the mobile training summary. + +--- + +### Task 1: Register the canonical Hangboard activity type + +**Files:** + +- Modify: `packages/training/src/activity-types.ts` +- Modify: `packages/training/src/activity-types.test.ts` +- Modify: `packages/training/src/training.ts` +- Modify: `packages/training/src/training.test.ts` +- Modify: `src/db/schema/enums.ts` +- Create: `drizzle/0072_add_hangboard_activity_type.sql` +- Modify: `drizzle/meta/_journal.json` + +**Interfaces:** + +- Produces the shared canonical string literal `"hangboard"`. +- Produces `formatActivityTypeLabel("hangboard") === "Hangboarding"`. +- Produces the Postgres enum value `fitness.canonical_activity_type = 'hangboard'`. + +- [ ] **Step 1: Add failing shared type and label assertions** + +Add these assertions to the existing activity-type/training test suites: + +```ts +expect(CANONICAL_ACTIVITY_TYPES).toContain("hangboard"); +expect(formatActivityTypeLabel("hangboard")).toBe("Hangboarding"); +expect(resolveProviderActivityType("Hang Ten", "hangboard")).toMatchObject({ + canonicalType: "hangboard", + providerType: "Hang Ten", +}); +``` + +- [ ] **Step 2: Run the focused tests and verify the expected failure** + +Run: + +```bash +rtk pnpm vitest packages/training/src/activity-types.test.ts packages/training/src/training.test.ts +``` + +Expected failure: the canonical type list and label map do not contain +`hangboard`. + +- [ ] **Step 3: Add the shared type and user-facing label** + +Add `"hangboard"` to `CANONICAL_ACTIVITY_TYPES` near `climbing` in +`packages/training/src/activity-types.ts` and add this label entry in +`packages/training/src/training.ts`: + +```ts +hangboard: "Hangboarding", +``` + +- [ ] **Step 4: Add the current-branch database migration** + +Create `drizzle/0072_add_hangboard_activity_type.sql`: + +```sql +ALTER TYPE fitness.canonical_activity_type + ADD VALUE IF NOT EXISTS 'hangboard' AFTER 'climbing'; +``` + +Update `drizzle/meta/_journal.json` with the repository's normal migration +entry for `0072_add_hangboard_activity_type`. Do not recreate or rename the +existing `0071_processing_alert_dismissal` migration. + +- [ ] **Step 5: Run the focused tests and schema checks** + +Run: + +```bash +rtk pnpm vitest packages/training/src/activity-types.test.ts packages/training/src/training.test.ts src/db/schema/enums.test.ts +rtk pnpm lint:db +``` + +Expected: all tests pass and the migration/schema policy check exits zero. + +- [ ] **Step 6: Commit the canonical type** + +```bash +rtk git add packages/training/src/activity-types.ts packages/training/src/activity-types.test.ts packages/training/src/training.ts packages/training/src/training.test.ts src/db/schema/enums.ts drizzle/0072_add_hangboard_activity_type.sql drizzle/meta/_journal.json +rtk git commit -m "feat: add Hangboarding activity type" +``` + +--- + +### Task 2: Port the typed Hang Ten Apple Health parser + +**Files:** + +- Modify: `src/providers/apple-health/workouts.ts` +- Modify: `src/providers/apple-health/streaming.ts` +- Modify: `src/providers/apple-health/parsing.test.ts` +- Modify: `src/providers/apple-health/parsing-extra.test.ts` +- Modify: `src/providers/apple-health/streaming.test.ts` + +**Interfaces:** + +- Consumes the `hangboard` type from Task 1. +- Produces `HealthWorkout.metadata?: Record`. +- Produces `HealthWorkout.hangTen?: HangTenWorkoutMetadata`. +- Produces `workoutExternalId(workout: HealthWorkout): string`. + +Use these exact domain shapes: + +```ts +interface HangTenActivitySegment { + stepID: string; + stepNumber: number; + kind: "work" | "rest"; + holdIDs: string[]; + holdType?: string; + sizeMillimeters?: number; + durationSeconds?: number; +} + +interface HangTenWorkoutMetadata { + sessionId?: string; + planName: string; + boardId?: string; + boardName?: string; + rawActivitySegments?: string; + activitySegments?: HangTenActivitySegment[]; + activitySegmentsError?: string; +} +``` + +- [ ] **Step 1: Port failing parser tests from the finalized historical ref** + +Use `rtk git show codex/hang-ten-apple-health-import:` to copy the +finalized Hang Ten cases into current colocated tests. The first test must +assert that a functional-strength workout with these metadata values: + +```ts +{ + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "7/3 Repeaters", + "HangTen.SessionID": "11111111-1111-4111-8111-111111111111", + "HangTen.BoardID": "metolius-compact-ii", + "HangTen.BoardName": "Metolius Compact II", + "HangTen.ActivitySegments": JSON.stringify({ + version: 1, + segments: [{ + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }], + }), +} +``` + +produces canonical type `hangboard`, source `Hang Ten`, plan name, board +metadata, and one parsed segment. Add failing cases for non-functional +strength workouts, missing/blank plan names, exact brand matching, malformed +JSON, empty segment arrays, and structurally invalid segments. + +- [ ] **Step 2: Run parser tests and verify they fail for missing behavior** + +```bash +rtk pnpm vitest src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.test.ts +``` + +Expected failure: `parseWorkout` does not accept metadata and the streaming +parser does not attach `MetadataEntry` values. + +- [ ] **Step 3: Implement the minimal typed metadata parser** + +Port the finalized parser behavior from `50c993e03` and `c4a653992`: + +- change `parseWorkout(attrs)` to `parseWorkout(attrs, metadata = {})`; +- parse and validate `HangTen.ActivitySegments` with Zod; +- preserve malformed raw segment JSON and a specific error string; +- override only qualifying functional-strength workouts to + `resolveProviderActivityType("Hang Ten", "hangboard")`; +- preserve `metadata` on the `HealthWorkout`; and +- use the Hang Ten session ID for `workoutExternalId` when present. + +In `streaming.ts`, collect nested `MetadataEntry` attributes only while a +workout is open, pass the map to `parseWorkout`, and clear the map when the +workout closes. Keep unrelated workout parsing unchanged. + +- [ ] **Step 4: Run parser tests and verify they pass** + +```bash +rtk pnpm vitest src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.test.ts +``` + +Expected: all parser and streaming tests pass with no warnings. + +- [ ] **Step 5: Commit the parser** + +```bash +rtk git add src/providers/apple-health/workouts.ts src/providers/apple-health/streaming.ts src/providers/apple-health/parsing.test.ts src/providers/apple-health/parsing-extra.test.ts src/providers/apple-health/streaming.test.ts +rtk git commit -m "feat: parse Hang Ten Apple Health metadata" +``` + +--- + +### Task 3: Persist Hang Ten metadata and activity intervals + +**Files:** + +- Create: `src/providers/apple-health/hang-ten-intervals.ts` +- Create: `src/providers/apple-health/hang-ten-intervals.test.ts` +- Modify: `src/providers/apple-health/db-insertion.ts` +- Modify: `src/providers/apple-health/db-insertion.test.ts` +- Modify: `src/providers/apple-health/db-insertion.integration.test.ts` +- Modify: `src/providers/apple-health/import.ts` +- Modify: `src/providers/apple-health/import.test.ts` +- Modify: `src/providers/apple-health/import.integration.test.ts` +- Modify: `src/providers/apple-health/test-helpers.ts` + +**Interfaces:** + +- Consumes `HealthWorkout.hangTen` and `workoutExternalId` from Task 2. +- Produces `hangTenIntervalLabel(segment)`, `buildHangTenIntervals(activityId, workout)`, and `replaceHangTenIntervals(db, activityId, workout)`. +- Produces activity rows with `name = HangTen.PlanName`, canonical type + `hangboard`, source `Hang Ten`, and raw payload key `hangTen`. + +- [ ] **Step 1: Write failing interval and insertion tests** + +Port the finalized tests from `cdf418e6f`, `b9eaa8cd2`, `e9a193e4a`, and +`c4a653992`. At minimum, assert: + +```ts +expect(hangTenIntervalLabel({ + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, +})).toBe("Step 1: 19 mm edge"); + +expect(hangTenIntervalLabel({ + stepID: "step-1-rest", + stepNumber: 1, + kind: "rest", + holdIDs: [], +})).toBe("Step 1: Rest"); +``` + +Add an integration test that imports a Hang Ten workout twice with changed +segments and proves the second import leaves exactly the replacement interval +set. Add a malformed-segment test that keeps the activity row, stores the +error in raw metadata, and inserts no intervals. + +- [ ] **Step 2: Run the focused tests and verify the expected failure** + +```bash +rtk pnpm vitest src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/import.test.ts +``` + +Expected failure: the interval helper and Hang Ten persistence behavior are +missing. + +- [ ] **Step 3: Add interval construction and replacement** + +Port `hang-ten-intervals.ts` from the finalized ref. Use `interval_index` in +segment order, `interval_type` values `work`/`rest`, and derive `ended_at` +only while all preceding durations are known. Delete existing intervals for +the activity before inserting the replacement set in one database statement. + +- [ ] **Step 4: Update workout insertion** + +Port the finalized `db-insertion.ts` behavior: + +- deduplicate by `workoutExternalId`; +- write `durationSeconds`, `distanceMeters`, `avgHeartRate`, and + `maxHeartRate` into the existing raw payload; +- include the typed Hang Ten metadata in `raw.hangTen`; +- use the plan name as the activity name; +- update an existing Hang Ten activity's name/raw payload on reimport; and +- call `replaceHangTenIntervals` after each returned Hang Ten activity ID. + +Do not add calories or provider-estimated expenditure to the raw payload. + +- [ ] **Step 5: Thread malformed metadata errors through import reporting** + +Port the existing non-fatal import error behavior from the historical ref. +The import still returns the workout count and writes the activity; it adds a +specific sync error containing the workout external ID and parser error when +`activitySegmentsError` is present. + +- [ ] **Step 6: Run unit and database integration tests** + +```bash +rtk pnpm vitest src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/import.test.ts +rtk pnpm test:integration -- src/providers/apple-health/db-insertion.integration.test.ts src/providers/apple-health/import.integration.test.ts +``` + +Expected: Hang Ten activities and replacement intervals are verified against +the real Postgres engine. + +- [ ] **Step 7: Commit the persistence layer** + +```bash +rtk git add src/providers/apple-health/hang-ten-intervals.ts src/providers/apple-health/hang-ten-intervals.test.ts src/providers/apple-health/db-insertion.ts src/providers/apple-health/db-insertion.test.ts src/providers/apple-health/db-insertion.integration.test.ts src/providers/apple-health/import.ts src/providers/apple-health/import.test.ts src/providers/apple-health/import.integration.test.ts src/providers/apple-health/test-helpers.ts +rtk git commit -m "feat: persist Hang Ten workout intervals" +``` + +--- + +### Task 4: Add server Hangboarding detail and summary contracts + +**Files:** + +- Create: `packages/server/src/repositories/hangboarding-repository.ts` +- Create: `packages/server/src/repositories/hangboarding-repository.test.ts` +- Create: `packages/server/src/repositories/hangboarding-repository.integration.test.ts` +- Modify: `packages/server/src/routers/activity.ts` +- Modify: `packages/server/src/routers/activity.test.ts` +- Modify: `packages/server/src/routers/activity.integration.test.ts` +- Modify: `packages/server/src/routers/climbing.ts` +- Modify: `packages/server/src/routers/climbing.test.ts` +- Modify: `packages/server/src/routers/climbing.integration.test.ts` + +**Interfaces:** + +Define and export these domain shapes from the repository: + +```ts +export interface HangboardingIntervalDetail { + id: string; + intervalIndex: number; + label: string | null; + intervalType: "work" | "rest" | null; + startedAt: string; + endedAt: string | null; + durationSeconds: number | null; +} + +export interface HangboardingDetail { + planName: string | null; + sessionId: string | null; + boardId: string | null; + boardName: string | null; + segmentsError: string | null; + intervals: HangboardingIntervalDetail[]; +} + +export interface HangboardingSummary { + sessionCount: number; + totalDurationSeconds: number; + averageDurationSeconds: number | null; + totalWorkDurationSeconds: number | null; + totalRestDurationSeconds: number | null; + workIntervalCount: number | null; + averageHeartRate: number | null; + peakHeartRate: number | null; + latestSession: { + activityId: string; + startedAt: string; + planName: string | null; + boardName: string | null; + durationSeconds: number; + } | null; + daily: Array<{ + date: string; + sessionCount: number; + durationSeconds: number; + workDurationSeconds: number | null; + restDurationSeconds: number | null; + }>; +} +``` + +- [ ] **Step 1: Write failing repository and router tests** + +Add tests for: + +- detail mapping from `activity.raw->'hangTen'` and ordered intervals; +- rejection of a non-owned/non-Hangboarding activity; +- a range with two sessions and work/rest intervals producing exact totals; +- null work/rest aggregates when no intervals have usable durations; +- null heart-rate aggregates when raw HR values are absent; +- `activity.hangboardDetails({ id })` returning the detail contract; and +- `climbing.hangboardingSummary({ days })` returning the summary contract. + +Use fixtures such as two sessions of 600 and 900 seconds, work intervals of +7 and 10 seconds, rest intervals of 53 and 50 seconds, and raw average/max +heart rates of 120/145 and 130/150. Assert that the server returns 2 sessions, +1500 total seconds, 750 average seconds, 17 work seconds, 103 rest seconds, +average HR 125, and peak HR 150. + +- [ ] **Step 2: Run tests and verify the expected failure** + +```bash +rtk pnpm vitest packages/server/src/repositories/hangboarding-repository.test.ts packages/server/src/routers/activity.test.ts packages/server/src/routers/climbing.test.ts +``` + +Expected failure: the repository and procedures do not exist. + +- [ ] **Step 3: Implement the repository with explicit Postgres schemas** + +Create `HangboardingRepository` with constructor dependencies: + +```ts +constructor( + database: Pick, + userId: string, + timezone: string, + accessWindow?: AccessWindow, +) +``` + +Implement: + +```ts +getDetail(activityId: string): Promise; +getSummary(days: number): Promise; +``` + +Use `fitness.v_activity` for visibility/access checks and join the member +`fitness.activity` rows for `raw.hangTen`, `started_at`, `ended_at`, and raw +heart-rate values. Restrict summary rows to `canonical_type = 'hangboard'`. +Aggregate interval durations with `SUM(EXTRACT(EPOCH ...))` only when matching +interval rows have usable end times; otherwise return `null` for that metric. +Group daily rows using `(started_at AT TIME ZONE timezone)::date`, and validate +all SQL rows with Zod schemas from `typed-sql.ts`. + +- [ ] **Step 4: Add tRPC procedures with actionable errors** + +Add to `activityRouter`: + +```ts +hangboardDetails: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM }) + .input(z.object({ id: z.guid() })) + .query(async ({ ctx, input }) => { + const repository = new HangboardingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + ); + return repository.getDetail(input.id); + }); +``` + +Return `NOT_FOUND` with `"Hangboarding details not found"` when the activity +is not visible or is not canonical `hangboard`. Add to `climbingRouter`: + +```ts +hangboardingSummary: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) + .input(daysInputSchema) + .query(async ({ ctx, input }) => { + const repository = new HangboardingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + ); + return repository.getSummary(input.days); + }); +``` + +Wrap repository failures with the existing climbing-query error/reporting +pattern and preserve the underlying actionable message in the tRPC error. + +- [ ] **Step 5: Run server unit and integration tests** + +```bash +rtk pnpm vitest packages/server/src/repositories/hangboarding-repository.test.ts packages/server/src/routers/activity.test.ts packages/server/src/routers/climbing.test.ts +rtk pnpm test:integration -- packages/server/src/repositories/hangboarding-repository.integration.test.ts packages/server/src/routers/activity.integration.test.ts packages/server/src/routers/climbing.integration.test.ts +``` + +Expected: exact summary math, access control, null handling, and error +contracts pass against unit fixtures and real Postgres data. + +- [ ] **Step 6: Commit the server contracts** + +```bash +rtk git add packages/server/src/repositories/hangboarding-repository.ts packages/server/src/repositories/hangboarding-repository.test.ts packages/server/src/repositories/hangboarding-repository.integration.test.ts packages/server/src/routers/activity.ts packages/server/src/routers/activity.test.ts packages/server/src/routers/activity.integration.test.ts packages/server/src/routers/climbing.ts packages/server/src/routers/climbing.test.ts packages/server/src/routers/climbing.integration.test.ts +rtk git commit -m "feat: expose Hangboarding activity details and summaries" +``` + +--- + +### Task 5: Thread the summary into the mobile training contract + +**Files:** + +- Modify: `packages/server/src/contracts/mobile-dashboard-contracts.ts` +- Modify: `packages/server/src/services/mobile-training-tab.ts` +- Modify: `packages/server/src/services/mobile-training-tab.test.ts` +- Modify: `packages/mobile/app/(tabs)/strain.tsx` +- Modify: `packages/mobile/app-tests/(tabs)/strain.test.tsx` + +**Interfaces:** + +- Extends `training.climbing` with `hangboarding: HangboardingSummary`. +- Keeps the existing `gradeProgression`, `volumeByGrade`, and `sessionSummary` fields unchanged. +- Mobile parses unknown server payloads through Zod and reports malformed + Hangboarding rows through the existing telemetry path. + +- [ ] **Step 1: Add failing mobile-contract and screen tests** + +Add a server service test asserting the returned shape: + +```ts +expect(result.climbing.hangboarding).toEqual({ + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: expect.objectContaining({ planName: "7/3 Repeaters" }), + daily: expect.any(Array), +}); +``` + +Add mobile tests for visible Hangboarding metrics, empty data, malformed +rows, and a failed training query with cached data preserved. + +- [ ] **Step 2: Run the tests and verify the expected failure** + +```bash +rtk pnpm vitest packages/server/src/services/mobile-training-tab.test.ts packages/mobile/app-tests/'(tabs)'/strain.test.tsx +``` + +Expected failure: the mobile output has no `climbing.hangboarding` field. + +- [ ] **Step 3: Add the server payload field** + +In `loadMobileTrainingTab`, instantiate the Hangboarding repository and add +`hangboardingSummary` to the existing `Promise.all`. Return it under +`climbing.hangboarding`, then add the same nested Zod schema to +`mobile-dashboard-contracts.ts`. + +- [ ] **Step 4: Parse and render the new mobile data** + +Extend `mobileClimbingDataSchema`, `emptyClimbingData`, and +`ClimbingSectionModel` in `strain.tsx`. Do not calculate totals from the daily +rows. Pass the server response into a dedicated `HangboardingSummary` component +that renders metric values and a duration trend using the existing `SparkLine`. + +When summary data is empty, show `No Hangboarding sessions`. When parsing +fails, retain valid climbing data and report the parse error through +`captureException`. + +- [ ] **Step 5: Run mobile/server tests and commit** + +```bash +rtk pnpm vitest packages/server/src/services/mobile-training-tab.test.ts packages/mobile/app-tests/'(tabs)'/strain.test.tsx +rtk git add packages/server/src/contracts/mobile-dashboard-contracts.ts packages/server/src/services/mobile-training-tab.ts packages/server/src/services/mobile-training-tab.test.ts packages/mobile/app/'(tabs)'/strain.tsx packages/mobile/app-tests/'(tabs)'/strain.test.tsx +rtk git commit -m "feat: add Hangboarding metrics to mobile training" +``` + +--- + +### Task 6: Add web Hangboarding summary and activity-detail presentation + +**Files:** + +- Create: `packages/web/src/components/HangboardingSummary.tsx` +- Create: `packages/web/src/components/HangboardingSummary.test.tsx` +- Create: `packages/web/src/components/HangboardingSummary.stories.tsx` +- Create: `packages/web/src/components/HangboardingDetail.tsx` +- Create: `packages/web/src/components/HangboardingDetail.test.tsx` +- Create: `packages/web/src/components/HangboardingDetail.stories.tsx` +- Modify: `packages/web/src/routes/training/climbing.tsx` +- Modify: `packages/web/src/routes/training/climbing.test.tsx` +- Modify: `packages/web/src/pages/ActivityDetailPage.tsx` +- Modify: `packages/web/src/pages/ActivityDetailPage.test.tsx` + +**Interfaces:** + +```ts +function HangboardingSummary({ + data, + loading, +}: { + data: HangboardingSummary | undefined; + loading: boolean; +}): JSX.Element; + +function HangboardingDetail({ + data, + loading, + error, +}: { + data: HangboardingDetail | undefined; + loading: boolean; + error: Error | null; +}): JSX.Element; +``` + +- [ ] **Step 1: Write failing component and route tests** + +Add component tests asserting: + +- metric labels `Sessions`, `Total Time`, `Avg Session`, `Work Time`, + `Rest Time`, and `Peak Heart Rate`; +- nullable work/rest/HR values render `—` rather than `0`; +- plan and board metadata render in the detail component; +- interval labels and durations render in index order; and +- loading and error states use the existing query-state conventions. + +Extend `climbing.test.tsx` to mock `trpc.climbing.hangboardingSummary.useQuery` +and assert the Hangboarding section receives the selected range input. Extend +`ActivityDetailPage.test.tsx` to mock `trpc.activity.hangboardDetails.useQuery` +and assert it is enabled only for `activityType === "hangboard"`. + +- [ ] **Step 2: Run the tests and verify the expected failure** + +```bash +rtk pnpm vitest packages/web/src/components/HangboardingSummary.test.tsx packages/web/src/components/HangboardingDetail.test.tsx packages/web/src/routes/training/climbing.test.tsx packages/web/src/pages/ActivityDetailPage.test.tsx +``` + +Expected failure: the components, mocked procedures, and rendered sections do +not yet exist. + +- [ ] **Step 3: Implement web components with server-provided values** + +Build `HangboardingSummary` from metric cards and a compact daily-duration +chart using the existing chart container/theme primitives. Format seconds and +heart rate for display only; never derive new metric values. Render the latest +plan/board as a link to the activity detail when `latestSession.activityId` +exists. + +Build `HangboardingDetail` as a focused metadata block plus interval table. +Use the existing activity-detail styles, `formatDurationSeconds`, and +`QueryStatePanel`. Render `segmentsError` as an actionable data-quality note +without hiding valid metadata or intervals. + +- [ ] **Step 4: Wire the web routes** + +In `climbing.tsx`, query `trpc.climbing.hangboardingSummary` with the same +training range input as the other climbing queries, render explicit loading, +error, and empty states, and invalidate it after relevant activity changes. + +In `ActivityDetailPage.tsx`, gate +`trpc.activity.hangboardDetails.useQuery({ id })` on `activityType === +"hangboard"`, render `HangboardingDetail`, and display the shared +`Hangboarding` label through `formatActivityTypeLabel`. + +- [ ] **Step 5: Run web tests and commit** + +```bash +rtk pnpm vitest packages/web/src/components/HangboardingSummary.test.tsx packages/web/src/components/HangboardingDetail.test.tsx packages/web/src/routes/training/climbing.test.tsx packages/web/src/pages/ActivityDetailPage.test.tsx +rtk git add packages/web/src/components/HangboardingSummary.tsx packages/web/src/components/HangboardingSummary.test.tsx packages/web/src/components/HangboardingSummary.stories.tsx packages/web/src/components/HangboardingDetail.tsx packages/web/src/components/HangboardingDetail.test.tsx packages/web/src/components/HangboardingDetail.stories.tsx packages/web/src/routes/training/climbing.tsx packages/web/src/routes/training/climbing.test.tsx packages/web/src/pages/ActivityDetailPage.tsx packages/web/src/pages/ActivityDetailPage.test.tsx +rtk git commit -m "feat: show Hangboarding details on web" +``` + +--- + +### Task 7: Add mobile Hangboarding activity-detail presentation + +**Files:** + +- Create: `packages/mobile/components/HangboardingSummary.tsx` +- Create: `packages/mobile/components/HangboardingSummary.test.tsx` +- Create: `packages/mobile/components/HangboardingSummary.stories.tsx` +- Create: `packages/mobile/components/HangboardingDetail.tsx` +- Create: `packages/mobile/components/HangboardingDetail.test.tsx` +- Create: `packages/mobile/components/HangboardingDetail.stories.tsx` +- Modify: `packages/mobile/app/activity/[id].tsx` +- Modify: `packages/mobile/app-tests/activity/[id].test.tsx` + +**Interfaces:** + +- Components consume the same inferred tRPC response shapes as web, adapted + to React Native layout and existing `colors`, `styles`, and formatting APIs. +- No tests, stories, or helpers are placed under `packages/mobile/app/`. + +- [ ] **Step 1: Write failing mobile component and route tests** + +Add tests that assert Hangboarding metadata, plan/board fields, interval rows, +empty metadata, and actionable error states. Extend the activity route test to +assert that `activity.hangboardDetails` is enabled only for `hangboard` and +that the screen shows `Hangboarding` and the imported plan name. + +- [ ] **Step 2: Run the tests and verify the expected failure** + +```bash +rtk pnpm vitest packages/mobile/components/HangboardingSummary.test.tsx packages/mobile/components/HangboardingDetail.test.tsx packages/mobile/app-tests/activity/'[id]'.test.tsx +``` + +Expected failure: the components and new tRPC mock are missing. + +- [ ] **Step 3: Implement the mobile components** + +Render the summary metrics in the existing card/grid style and use `SparkLine` +for the server-provided daily duration series. Render detail intervals in a +scroll-safe vertical list with work/rest labels, optional timestamps, and +formatted durations. Show `—` for nullable metrics and preserve server error +messages. + +- [ ] **Step 4: Wire the activity detail screen** + +Add the `hangboardDetails` query beside the existing activity queries, enabled +only when the loaded activity is canonical `hangboard`. Add a Hangboarding +section after the stats grid and before generic sensor charts. Invalidate the +query with the existing activity recompute/delete invalidations. + +- [ ] **Step 5: Run mobile tests and commit** + +```bash +rtk pnpm vitest packages/mobile/components/HangboardingSummary.test.tsx packages/mobile/components/HangboardingDetail.test.tsx packages/mobile/app-tests/activity/'[id]'.test.tsx +rtk git add packages/mobile/components/HangboardingSummary.tsx packages/mobile/components/HangboardingSummary.test.tsx packages/mobile/components/HangboardingSummary.stories.tsx packages/mobile/components/HangboardingDetail.tsx packages/mobile/components/HangboardingDetail.test.tsx packages/mobile/components/HangboardingDetail.stories.tsx packages/mobile/app/activity/'[id]'.tsx packages/mobile/app-tests/activity/'[id]'.test.tsx +rtk git commit -m "feat: show Hangboarding details on mobile" +``` + +--- + +### Task 8: Full verification and handoff + +**Files:** + +- Modify only if verification reveals a real defect in the changed behavior. + +- [ ] **Step 1: Run changed-file tests** + +```bash +rtk pnpm test:changed +rtk pnpm test:changed:all +``` + +Expected: changed unit and integration suites pass with no skipped Hang Ten +coverage. + +- [ ] **Step 2: Run repository quality gates** + +```bash +rtk pnpm lint +rtk pnpm typecheck +rtk pnpm lint:analytics-policy +``` + +Expected: all gates exit zero without raised limits, disabled rules, or +warn-and-continue behavior. + +- [ ] **Step 3: Inspect the final diff and working tree** + +```bash +rtk git diff HEAD~8..HEAD --check +rtk git status --short +``` + +Confirm only the Hangboarding implementation, tests, docs, and migrations are +committed. Preserve the pre-existing untracked `paseo.json` unless it was +created by the task. + +- [ ] **Step 4: Complete the handoff** + +Report the importer commits reused, the new current-branch migration number, +the server contracts, web/mobile surfaces, exact verification commands and +results, and any remaining limitation (Apple Health only supplies the Hang +Ten metadata/segments that were exported). Include the required retrospective +and concrete suggestions for future AGENTS/README/runbook improvements. diff --git a/docs/superpowers/specs/2026-08-07-hang-ten-apple-health-import-design.md b/docs/superpowers/specs/2026-08-07-hang-ten-apple-health-import-design.md new file mode 100644 index 0000000000..28a6261567 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-hang-ten-apple-health-import-design.md @@ -0,0 +1,121 @@ +# Hang Ten Apple Health Import Enrichment Design + +## Goal + +Import Hang Ten completed hangboard sessions through Dofek's existing Apple +Health export flow without adding a separate direct Hang Ten sync provider. +Hang Ten already saves completed routines as Apple Health functional strength +workouts with app-specific metadata. Dofek should preserve that metadata, +surface the workout with a readable Hang Ten name, classify it as +`hangboard`, and expose the ordered work/rest segments as activity +intervals. + +## Current State + +Dofek's Apple Health importer streams `Workout`, `WorkoutStatistics`, routes, +records, sleep, and category data from `export.xml`. Workout parsing currently +keeps the canonical activity type, source name, duration, distance, heart-rate +summary, start date, end date, and route points. Nested workout +metadata entries are ignored. + +Hang Ten saves completed sessions with: + +- `HKMetadataKeyWorkoutBrandName = "Hang Ten"` +- `HangTen.PlanName` +- `HangTen.SessionID` +- `HangTen.BoardID` +- `HangTen.BoardName` +- `HangTen.ActivitySegments` + +`HangTen.ActivitySegments` is a stable JSON string containing a version and an +ordered list of recorded work/rest segments. Each segment includes step ID, +step number, segment kind, resolved hold IDs, optional hold type, optional hold +size in millimeters, and optional duration. + +## Approach + +Extend the Apple Health import path rather than creating a new provider ID for +this first version. Imported rows keep `provider_id = apple_health`; Hang Ten is +represented as the activity source and preserved raw metadata. Add +`hangboard` as a first-class canonical activity type in the shared training +package and the database enum so Hang Ten sessions are not flattened into +generic functional strength training. + +The XML streaming parser will collect nested `MetadataEntry` elements while a +`Workout` is open. On workout close, it will attach the metadata map to the +`HealthWorkout` object before the workout batch is flushed. + +Workout parsing will recognize Hang Ten when all of these are true: + +- the Apple Health workout activity type maps to `functional_strength`; +- `HKMetadataKeyWorkoutBrandName` is `Hang Ten`; +- `HangTen.PlanName` is present after trimming whitespace. + +Recognized Hang Ten workouts will use: + +- `name = HangTen.PlanName` +- `sourceName = Hang Ten` +- `externalId = ah:workout:` when a session ID exists, + otherwise the existing start-date-based Apple Health external ID +- `activityType = hangboard` + +The raw activity payload will include the existing workout summary fields plus +the Hang Ten metadata: session ID, plan name, board ID, board name, and parsed +activity segments. If segment JSON cannot be parsed, the importer should keep +the raw metadata string in `activity.raw`, add a sync error for that workout, +and still import the workout row. + +## Activity Intervals + +For Hang Ten workouts with parsed segments, insert one `activity_interval` row +per segment. Existing intervals for the activity should be replaced on reimport +so the interval set remains idempotent. + +Intervals use the segment order as `interval_index`. Labels should be +layman-readable: + +- rest segments: `Step : Rest` +- work segments with size and type: `Step : mm ` +- work segments with only hold type: `Step : ` +- work segments without a descriptor: `Step : Work` + +When all preceding segments have durations, `started_at` and `ended_at` are +derived from the workout start time and cumulative duration. If a segment has +no duration, set `started_at` to the best known cumulative time and leave +`ended_at` null; later segments continue from the last known cumulative time +only when their offsets are unambiguous. + +Use `interval_type = "work"` or `"rest"` from the segment kind. + +## Error Handling + +Malformed Hang Ten segment JSON is an import-quality issue for that workout, +not a reason to drop the workout entirely. The importer should report a +specific sync error that includes the workout external ID and continue. Missing +optional Hang Ten metadata should simply leave those raw fields absent. + +The parser should continue to fail loudly for invalid Apple Health export XML +or database write failures, matching the existing import behavior. + +## Testing + +Add unit coverage for: + +- parsing workout metadata entries inside a workout; +- detecting Hang Ten workouts from metadata; +- preserving raw Hang Ten metadata and parsed segments; +- handling malformed segment JSON without dropping the workout; +- deriving interval labels and times from ordered segments. + +Add integration coverage using a minimal Apple Health export fixture containing +a functional strength workout with Hang Ten metadata. Verify that import +creates one activity row, stores raw Hang Ten details, and inserts the expected +activity intervals. + +## Out Of Scope + +This design does not add a direct Hang Ten API, cloud account sync, or a +separate Hang Ten file-import provider. It also does not add new database +columns for hangboard-specific fields; raw Hang Ten source data stays in the +existing JSON payload unless a later UI or analytics feature proves that a +first-class schema change is needed. diff --git a/docs/superpowers/specs/2026-08-10-hangboarding-ui-design.md b/docs/superpowers/specs/2026-08-10-hangboarding-ui-design.md new file mode 100644 index 0000000000..231fd7ba71 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-hangboarding-ui-design.md @@ -0,0 +1,135 @@ +# Hangboarding Import and Training UI Design + +## Goal + +Surface Hang Ten workouts imported through Apple Health as first-class +Hangboarding activities, with their plan/board metadata and ordered work/rest +segments visible on activity details and aggregate metrics visible on the +climbing training page. + +## Existing implementation to reuse + +The finalized Apple Health importer already exists on the local +`codex/hang-ten-apple-health-import` ref. Its implementation: + +- recognizes functional-strength workouts branded `Hang Ten` with a plan name; +- preserves Hang Ten metadata and parsed activity segments in `activity.raw`; +- classifies recognized workouts as the canonical `hangboard` activity type; +- stores ordered segments as `activity_interval` rows and replaces them + idempotently on reimport; and +- keeps malformed segment JSON non-fatal while retaining the workout row. + +The current branch does not contain those changes. The implementation will +port the finalized importer changes and reconcile them with the current +migration journal instead of blindly reusing the historical migration number. + +Hang Ten's Apple Health contract is based on functional-strength workouts, +the workout brand metadata key, and serialized app metadata. The relevant +HealthKit definitions are documented by Apple for +[`HKWorkoutActivityType.functionalStrengthTraining`](https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype/functionalstrengthtraining) +and [`HKMetadataKeyWorkoutBrandName`](https://developer.apple.com/documentation/healthkit/hkmetadatakeyworkoutbrandname). + +## Data flow + +```text +Apple Health export.xml + -> streaming Workout + MetadataEntry parser + -> typed Hang Ten metadata and segments + -> activity(canonical_type = hangboard, name = plan name) + -> activity_interval rows for work/rest segments + -> server activity detail and climbing summary queries + -> web + mobile rendering +``` + +The importer remains Apple Health-backed (`provider_id = apple_health`). No +direct Hang Ten provider or duplicate Hang Ten tables are added. Raw metadata +remains the provenance source; intervals are the serving representation for +ordered work/rest timing. + +## Activity detail behavior + +Hang Ten activities display the user-facing type label **Hangboarding** while +retaining the internal canonical value `hangboard`. The activity name uses the +Hang Ten plan name when present. Existing activity header metrics continue to +show duration, heart-rate metrics, and source information when available. + +The detail page adds a Hangboarding section when intervals or Hang Ten raw +metadata are present. It shows: + +- plan name; +- board name and board identifier when available; +- session identifier when available; +- ordered work/rest intervals; +- interval labels such as `Step 1: 19 mm edge` or `Step 1: Rest`; +- work/rest duration and timestamps when the imported segment durations make + them unambiguous; and +- an actionable server error when interval data cannot load. + +Web and mobile use the existing activity-detail and interval APIs. No client +calculates interval timestamps, durations, or aggregate values. + +## Climbing page behavior + +Add a Hangboarding section to the web climbing page and the mobile climbing +section. The server computes the selected date-range summary from canonical +`hangboard` activities and their imported intervals. The response contains: + +- session count; +- total hangboarding duration; +- average session duration; +- total work duration and total rest duration when interval durations are + available; +- work-interval count; +- average recorded heart rate and highest recorded heart rate when present; +- most recent plan and board metadata; and +- a daily trend of session count, total duration, work duration, and rest + duration. + +Missing interval or heart-rate data remains explicitly nullable and does not +become zero. Calories, estimated expenditure, and inferred hang loads are not +ingested or displayed. + +The web page renders summary metric cards and a compact trend visualization. +The mobile page renders the same server-provided metrics in the existing +Climbing card with an empty state when no Hangboarding data exists. Both +surfaces link each listed session to its activity detail page. + +## Server contracts and boundaries + +- Add repository methods for Hangboarding detail and date-range summaries; + routers remain thin and validate every response with Zod. +- Reuse `activity_interval` for imported segments rather than adding a + Hangboarding-specific interval table. +- Keep all aggregation and timestamp derivation on the server. +- Use the selected training range and the user's timezone for date grouping. +- Preserve cached data during background refetches and show server error + messages through the existing query-state patterns. +- Register `hangboard` in shared activity labels/icons and in the database + canonical activity enum through a forward-only migration using the current + journal's next available migration number. + +## Testing + +Test-first coverage will include: + +- existing importer behavior from the finalized historical implementation, + ported to the current branch; +- real-database interval replacement and Hang Ten activity insertion; +- repository summary calculations with missing interval and heart-rate values; +- router response validation and actionable errors; +- web activity-detail Hangboarding metadata and interval rendering; +- web climbing-page Hangboarding summary and trend rendering; and +- mobile activity-detail and climbing-section parity, including empty/error + states. + +Database-dependent behavior will use executable integration tests against the +real database. Unit tests will cover pure parsing, formatting, and rendering +behavior without external services. + +## Scope boundaries + +This change does not add a direct Hang Ten API, infer individual hangs from +heart-rate data, store duplicate Hang Ten-specific columns, or change generic +strength workouts that lack the required Hang Ten metadata. Historical Apple +Health workouts become enriched when reimported through the existing import +flow; no request-time backfill is added. diff --git a/drizzle/0072_add_hangboard_activity_type.sql b/drizzle/0072_add_hangboard_activity_type.sql new file mode 100644 index 0000000000..2d4bb32652 --- /dev/null +++ b/drizzle/0072_add_hangboard_activity_type.sql @@ -0,0 +1,2 @@ +ALTER TYPE fitness.canonical_activity_type +ADD VALUE IF NOT EXISTS 'hangboard' AFTER 'climbing'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b0e4d2f3b1..b21a74f1a1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -589,6 +589,13 @@ "when": 1786230300000, "tag": "0071_processing_alert_dismissal", "breakpoints": true + }, + { + "idx": 84, + "version": "7", + "when": 1786372124000, + "tag": "0072_add_hangboard_activity_type", + "breakpoints": true } ] } diff --git a/native/fit-decoder/vcpkg-configuration.json b/native/fit-decoder/vcpkg-configuration.json index f6010a2b1d..d67cd7935c 100644 --- a/native/fit-decoder/vcpkg-configuration.json +++ b/native/fit-decoder/vcpkg-configuration.json @@ -1,7 +1,7 @@ { "default-registry": { "kind": "builtin", - "baseline": "cd61e1e26a038e82d6550a3ebbe0fbbfe7da78e3" + "baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d" }, "overlay-ports": [ "vcpkg-ports" diff --git a/package.json b/package.json index 1e16498ff1..ea8e5f61f4 100644 --- a/package.json +++ b/package.json @@ -219,8 +219,8 @@ "dependencies": { "@ai-sdk/otel": "1.0.47", "@aws-crypto/client-node": "5.0.0", - "@aws-sdk/client-s3": "3.1050.0", - "@aws-sdk/s3-request-presigner": "3.1050.0", + "@aws-sdk/client-s3": "3.1106.0", + "@aws-sdk/s3-request-presigner": "3.1106.0", "@bull-board/api": "8.1.2", "@bull-board/express": "8.1.2", "@clickhouse/client": "1.23.1", @@ -293,7 +293,7 @@ "@types/yauzl": "2.10.3", "@vitest/coverage-v8": "3.2.4", "cspell": "10.0.1", - "cypress": "15.18.1", + "cypress": "15.19.0", "dependency-cruiser": "17.3.10", "drizzle-dbml-generator": "0.10.0", "drizzle-kit": "0.31.10", diff --git a/packages/eight-sleep/package.json b/packages/eight-sleep/package.json index 22b2ef4aa8..695b523c2d 100644 --- a/packages/eight-sleep/package.json +++ b/packages/eight-sleep/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/eight-sleep", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Eight Sleep API client using reverse-engineered authentication", "type": "module", "license": "MIT", diff --git a/packages/garmin-connect/package.json b/packages/garmin-connect/package.json index 90c4d52e78..456aec95ea 100644 --- a/packages/garmin-connect/package.json +++ b/packages/garmin-connect/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/garmin-connect", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Garmin Connect API client using the internal SSO + OAuth authentication flow", "type": "module", "license": "MIT", diff --git a/packages/ml/pyproject.toml b/packages/ml/pyproject.toml index 2c871135fd..1ac3b8cbdc 100644 --- a/packages/ml/pyproject.toml +++ b/packages/ml/pyproject.toml @@ -34,7 +34,7 @@ dev = [ ] [build-system] -requires = ["uv_build>=0.11.30,<0.12.0"] +requires = ["uv_build>=0.12.1,<0.13.0"] build-backend = "uv_build" # ── ruff (linter + formatter) ── diff --git a/packages/mobile/app-tests/(tabs)/activities.test.tsx b/packages/mobile/app-tests/(tabs)/activities.test.tsx index 027d03db3d..0d24f8b364 100644 --- a/packages/mobile/app-tests/(tabs)/activities.test.tsx +++ b/packages/mobile/app-tests/(tabs)/activities.test.tsx @@ -467,6 +467,49 @@ describe("ActivitiesScreen", () => { expect(screen.getByText("0 m")).toBeDefined(); }); + it("renders available partial overview measurements and comparisons", () => { + mockOverviewQuery = { + data: { + activityCount: 4, + totalMinutes: 280, + totalDistanceMeters: 12500, + totalDistanceState: { status: "available" }, + totalElevationGainM: 180, + totalElevationState: { status: "available" }, + activityTypes: ["running", "cycling"], + comparison: { + periodLabel: "previous 4 weeks", + activityCount: { magnitude: 1, trend: "higher" }, + totalMinutes: { magnitude: 60, trend: "higher" }, + totalDistanceMeters: { + magnitude: 2500, + trend: "higher", + state: { status: "available" }, + }, + totalElevationGainM: { + magnitude: 50, + trend: "higher", + state: { status: "available" }, + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }; + + render(); + + expect(screen.getByText("12.5 km")).toBeDefined(); + expect(screen.getByText("180 m")).toBeDefined(); + expect(screen.getByText("2.5 km more vs previous 4 weeks")).toBeDefined(); + expect(screen.getByText("50 m more vs previous 4 weeks")).toBeDefined(); + expect( + screen.getByLabelText("Distance 12.5 km. 2.5 km more vs previous 4 weeks"), + ).toBeDefined(); + expect(screen.getByLabelText("Elevation 180 m. 50 m more vs previous 4 weeks")).toBeDefined(); + }); + it("passes selected activity type to the activity list query", () => { mockOverviewQuery = { data: { diff --git a/packages/mobile/app-tests/(tabs)/strain.test.tsx b/packages/mobile/app-tests/(tabs)/strain.test.tsx index c2e3531b77..eca7dacd87 100644 --- a/packages/mobile/app-tests/(tabs)/strain.test.tsx +++ b/packages/mobile/app-tests/(tabs)/strain.test.tsx @@ -99,6 +99,18 @@ function defaultMockTrainingData(): MockTrainingData { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }, }; } @@ -169,7 +181,9 @@ vi.mock("../../lib/trpc", () => ({ status: { useQuery: () => ({ data: undefined, isLoading: false, error: null }), }, - dismiss: { useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }) }, + dismiss: { + useMutation: () => ({ mutate: vi.fn(), isPending: false, error: null }), + }, }, useUtils: () => ({ mobileDashboard: { @@ -825,6 +839,121 @@ describe("StrainScreen recent activity navigation", () => { }); }); + it("renders server-computed Hangboarding summary metrics and duration trend", async () => { + mockTrainingState.data = { + ...defaultMockTrainingData(), + climbing: { + ...defaultMockTrainingData().climbing, + hangboarding: { + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "7/3 Repeaters", + boardName: "Tension Board", + durationSeconds: 900, + }, + daily: [ + { + date: "2026-08-07", + sessionCount: 1, + durationSeconds: 600, + workDurationSeconds: 7, + restDurationSeconds: 53, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 900, + workDurationSeconds: 10, + restDurationSeconds: 50, + }, + ], + }, + }, + }; + + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); + render(); + + for (const label of [ + "Sessions", + "Total Time", + "Avg Session", + "Work Time", + "Rest Time", + "Work Intervals", + "Avg Heart Rate", + "Peak Heart Rate", + ]) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.getAllByText("2").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("25m")).toBeTruthy(); + expect(screen.getByText("13m")).toBeTruthy(); + expect(screen.getByText("17s")).toBeTruthy(); + expect(screen.getByText("2m")).toBeTruthy(); + expect(screen.getByText("125 bpm")).toBeTruthy(); + expect(screen.getByText("150 bpm")).toBeTruthy(); + expect(screen.getByText("7/3 Repeaters")).toBeTruthy(); + expect(screen.getByText("Tension Board")).toBeTruthy(); + expect(screen.getByText("15m")).toBeTruthy(); + expect(screen.getByText(/2026/)).toBeTruthy(); + }); + + it("shows the Hangboarding empty state", async () => { + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); + render(); + + expect(screen.getByText("No Hangboarding sessions yet.")).toBeTruthy(); + }); + + it("reports malformed Hangboarding daily rows while rendering valid summary metrics", async () => { + mockTrainingState.data = { + ...defaultMockTrainingData(), + climbing: { + ...defaultMockTrainingData().climbing, + hangboarding: { + ...defaultMockTrainingData().climbing.hangboarding, + sessionCount: 1, + totalDurationSeconds: 600, + averageDurationSeconds: 600, + daily: [{ date: "bad", sessionCount: "bad", durationSeconds: 600 }], + }, + }, + }; + + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); + render(); + + expect(screen.getByText("Sessions")).toBeTruthy(); + expect(screen.getByText("1")).toBeTruthy(); + expect(screen.getByText(/strain:climbing.hangboarding.daily/)).toBeTruthy(); + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + context: "strain:climbing.hangboarding.daily", + zodError: expect.any(Object), + }); + }); + + it("preserves cached Hangboarding data during a failed training refresh", async () => { + mockTrainingState.data = defaultMockTrainingData(); + mockTrainingState.isError = true; + mockTrainingState.error = new Error("Training refresh failed"); + + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); + render(); + + expect(screen.getByText("No Hangboarding sessions yet.")).toBeTruthy(); + expect(screen.queryByText("Training refresh failed")).toBeNull(); + }); + it("shows the best climbing grade instead of the most recent lower grade", async () => { mockTrainingState.data = { ...defaultMockTrainingData(), diff --git a/packages/mobile/app-tests/activity/[id].test.tsx b/packages/mobile/app-tests/activity/[id].test.tsx index 6d0dabbdcd..5305d677bc 100644 --- a/packages/mobile/app-tests/activity/[id].test.tsx +++ b/packages/mobile/app-tests/activity/[id].test.tsx @@ -140,10 +140,14 @@ vi.mock("../../theme", () => ({ text: "#fff", textSecondary: "#aaa", textTertiary: "#666", + blue: "#00f", + warning: "#f90", accent: "#00f", positive: "#0f0", danger: "#f00", }, + radius: { md: 8, xl: 16, full: 999 }, + spacing: { xs: 4, sm: 8, md: 16, lg: 24 }, })); vi.mock("@dofek/format/format", async (importOriginal) => { @@ -189,6 +193,9 @@ vi.mock("@dofek/scoring/colors", () => ({ info: "#2563eb", elevated: "#ea580c", }, + operationalStatusColors: { + danger: { surface: "#fee2e2", border: "#dc2626", foreground: "#991b1b" }, + }, textColors: { neutral: "#8aaa8a" }, })); @@ -223,6 +230,7 @@ const mockHrZonesQuery = vi.fn(); const mockPowerZonesQuery = vi.fn(); const mockStrengthExercisesQuery = vi.fn(); const mockClimbingEntriesQuery = vi.fn(); +const mockHangboardDetailsQuery = vi.fn(); const mockRecomputeMutate = vi.fn(); const mockRecomputeShouldFail = vi.fn(() => false); const mockActivityByIdInvalidate = vi.fn().mockResolvedValue(undefined); @@ -230,6 +238,7 @@ const mockActivityStreamInvalidate = vi.fn().mockResolvedValue(undefined); const mockActivityHrZonesInvalidate = vi.fn().mockResolvedValue(undefined); const mockActivityPowerZonesInvalidate = vi.fn().mockResolvedValue(undefined); const mockActivityStrengthExercisesInvalidate = vi.fn().mockResolvedValue(undefined); +const mockActivityHangboardDetailsInvalidate = vi.fn().mockResolvedValue(undefined); const mockActivityListInvalidate = vi.fn().mockResolvedValue(undefined); const mockCalendarWeekListInvalidate = vi.fn().mockResolvedValue(undefined); const mockCalendarActivityOverviewInvalidate = vi.fn().mockResolvedValue(undefined); @@ -243,6 +252,7 @@ vi.mock("../../lib/trpc", () => ({ hrZones: { useQuery: (...args: unknown[]) => mockHrZonesQuery(...args) }, powerZones: { useQuery: (...args: unknown[]) => mockPowerZonesQuery(...args) }, strengthExercises: { useQuery: (...args: unknown[]) => mockStrengthExercisesQuery(...args) }, + hangboardDetails: { useQuery: (...args: unknown[]) => mockHangboardDetailsQuery(...args) }, recompute: { useMutation: (options?: { onSuccess?: () => Promise; @@ -274,6 +284,7 @@ vi.mock("../../lib/trpc", () => ({ hrZones: { invalidate: mockActivityHrZonesInvalidate }, powerZones: { invalidate: mockActivityPowerZonesInvalidate }, strengthExercises: { invalidate: mockActivityStrengthExercisesInvalidate }, + hangboardDetails: { invalidate: mockActivityHangboardDetailsInvalidate }, list: { invalidate: mockActivityListInvalidate }, }, calendar: { @@ -363,6 +374,7 @@ beforeEach(() => { mockPowerZonesQuery.mockClear(); mockStrengthExercisesQuery.mockClear(); mockClimbingEntriesQuery.mockClear(); + mockHangboardDetailsQuery.mockClear(); mockRecomputeMutate.mockClear(); mockRecomputeShouldFail.mockReset(); mockRecomputeShouldFail.mockReturnValue(false); @@ -371,6 +383,7 @@ beforeEach(() => { mockActivityHrZonesInvalidate.mockClear(); mockActivityPowerZonesInvalidate.mockClear(); mockActivityStrengthExercisesInvalidate.mockClear(); + mockActivityHangboardDetailsInvalidate.mockClear(); mockActivityListInvalidate.mockClear(); mockCalendarWeekListInvalidate.mockClear(); mockCalendarActivityOverviewInvalidate.mockClear(); @@ -383,6 +396,7 @@ beforeEach(() => { mockPowerZonesQuery.mockReturnValue({ data: null, isLoading: false }); mockStrengthExercisesQuery.mockReturnValue({ data: [], isLoading: false }); mockClimbingEntriesQuery.mockReturnValue({ data: [], isLoading: false }); + mockHangboardDetailsQuery.mockReturnValue({ data: undefined, isLoading: false, error: null }); }); describe("ActivityDetailScreen", () => { @@ -409,6 +423,7 @@ describe("ActivityDetailScreen", () => { expect(mockActivityHrZonesInvalidate).toHaveBeenCalledWith({ id: activityId }); expect(mockActivityPowerZonesInvalidate).toHaveBeenCalledWith({ id: activityId }); expect(mockActivityStrengthExercisesInvalidate).toHaveBeenCalledWith({ id: activityId }); + expect(mockActivityHangboardDetailsInvalidate).toHaveBeenCalledWith({ id: activityId }); expect(mockActivityListInvalidate).toHaveBeenCalled(); expect(mockCalendarWeekListInvalidate).toHaveBeenCalled(); expect(mockCalendarActivityOverviewInvalidate).toHaveBeenCalled(); @@ -451,6 +466,40 @@ describe("ActivityDetailScreen", () => { expect(screen.getByText("Morning Ride")).toBeTruthy(); }); + it("enables Hangboarding details only for canonical hangboard activities", async () => { + mockByIdQuery.mockReturnValue({ + data: { ...baseCyclingActivity, activityType: "cycling" }, + isLoading: false, + error: null, + }); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); + render(React.createElement(ActivityDetailScreen)); + expect(getQueryEnabledFlag(mockHangboardDetailsQuery.mock.calls[0]?.[1])).toBe(false); + + mockByIdQuery.mockReturnValue({ + data: { ...baseCyclingActivity, activityType: "hangboard", name: "Repeaters" }, + isLoading: false, + error: null, + }); + mockHangboardDetailsQuery.mockReturnValue({ + data: { + planName: "Imported 7/3", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [], + }, + isLoading: false, + error: null, + }); + const rerendered = render(React.createElement(ActivityDetailScreen)); + rerendered.rerender(React.createElement(ActivityDetailScreen)); + expect(getQueryEnabledFlag(mockHangboardDetailsQuery.mock.calls.at(-1)?.[1])).toBe(true); + expect(screen.getByText("Hangboarding")).toBeTruthy(); + expect(screen.getByText("Imported 7/3")).toBeTruthy(); + }); + it("renders the activity's session perceived exertion control", async () => { mockByIdQuery.mockReturnValue({ data: { ...baseCyclingActivity, perceivedExertion: 7 }, diff --git a/packages/mobile/app/(tabs)/strain.tsx b/packages/mobile/app/(tabs)/strain.tsx index 9a4ec606a3..6c84d76f63 100644 --- a/packages/mobile/app/(tabs)/strain.tsx +++ b/packages/mobile/app/(tabs)/strain.tsx @@ -30,6 +30,7 @@ import { SparkLine } from "../../components/charts/SparkLine"; import { StrainGauge } from "../../components/charts/StrainGauge"; import { VerticalAscentChart } from "../../components/charts/VerticalAscentChart"; import { DaySelector } from "../../components/DaySelector"; +import { HangboardingSummary } from "../../components/HangboardingSummary"; import { ProcessingStatusWidget } from "../../components/ProcessingStatusWidget"; import { ProgressiveOverloadCards } from "../../components/ProgressiveOverloadCards"; import { QueryStatePanel } from "../../components/QueryStatePanel"; @@ -76,21 +77,56 @@ const mobileClimbingSessionSummaryRowSchema = z.object({ hardestRouteGrade: z.string().nullable(), }); +const mobileHangboardingDailyRowSchema = z.object({ + date: z.string(), + sessionCount: z.number().int().nonnegative(), + durationSeconds: z.number().nonnegative(), + workDurationSeconds: z.number().nonnegative().nullable(), + restDurationSeconds: z.number().nonnegative().nullable(), +}); + +const mobileHangboardingSummarySchema = z.object({ + sessionCount: z.number().int().nonnegative(), + totalDurationSeconds: z.number().nonnegative(), + averageDurationSeconds: z.number().nonnegative().nullable(), + totalWorkDurationSeconds: z.number().nonnegative().nullable(), + totalRestDurationSeconds: z.number().nonnegative().nullable(), + workIntervalCount: z.number().int().nonnegative().nullable(), + averageHeartRate: z.number().nonnegative().nullable(), + peakHeartRate: z.number().nonnegative().nullable(), + latestSession: z + .object({ + activityId: z.string(), + startedAt: z.string(), + planName: z.string().nullable(), + boardName: z.string().nullable(), + durationSeconds: z.number().nonnegative(), + }) + .nullable(), + daily: z.unknown(), +}); + const mobileClimbingDataSchema = z.object({ gradeProgression: z.array(mobileClimbingGradeProgressionRowSchema), volumeByGrade: z.array(mobileClimbingVolumeByGradeRowSchema), sessionSummary: z.array(mobileClimbingSessionSummaryRowSchema), + hangboarding: z.object({ + ...mobileHangboardingSummarySchema.shape, + daily: z.array(mobileHangboardingDailyRowSchema), + }), }); const mobileClimbingPayloadSchema = z.object({ gradeProgression: z.unknown().optional(), volumeByGrade: z.unknown().optional(), sessionSummary: z.unknown().optional(), + hangboarding: z.unknown().optional(), }); type MobileClimbingGradeProgressionRow = z.infer; type MobileClimbingVolumeByGradeRow = z.infer; type MobileClimbingSessionSummaryRow = z.infer; +type MobileHangboardingSummary = z.infer["hangboarding"]; type MobileClimbingData = z.infer; interface MobileClimbingParseResult { @@ -102,6 +138,18 @@ const emptyClimbingData: MobileClimbingData = { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }; const reportedTrainingErrors = new WeakSet(); @@ -146,14 +194,41 @@ function parseMobileClimbingData(value: unknown): MobileClimbingParseResult { payloadResult.data.sessionSummary ?? [], "strain:climbing.sessionSummary", ); + const hangboardingResult = mobileHangboardingSummarySchema.safeParse( + payloadResult.data.hangboarding ?? emptyClimbingData.hangboarding, + ); + const hangboardingDaily = hangboardingResult.success + ? safeParseRows( + mobileHangboardingDailyRowSchema, + hangboardingResult.data.daily, + "strain:climbing.hangboarding.daily", + ) + : { data: [], error: null }; + const hangboarding = hangboardingResult.success + ? { ...hangboardingResult.data, daily: hangboardingDaily.data } + : emptyClimbingData.hangboarding; + const hangboardingError = hangboardingResult.success + ? hangboardingDaily.error + : (() => { + const parseError = new Error( + `strain:climbing.hangboarding: Zod parse failed: ${hangboardingResult.error.message}`, + ); + captureException(parseError, { + context: "strain:climbing.hangboarding", + zodError: hangboardingResult.error.format(), + }); + return parseError; + })(); return { data: { gradeProgression: gradeProgression.data, volumeByGrade: volumeByGrade.data, sessionSummary: sessionSummary.data, + hangboarding, }, - error: gradeProgression.error ?? volumeByGrade.error ?? sessionSummary.error, + error: + gradeProgression.error ?? volumeByGrade.error ?? sessionSummary.error ?? hangboardingError, }; } @@ -183,6 +258,10 @@ class ClimbingSectionModel { get sessions(): MobileClimbingSessionSummaryRow[] { return this.#data.sessionSummary; } + + get hangboarding(): MobileHangboardingSummary { + return this.#data.hangboarding; + } } export default function StrainScreen() { @@ -498,7 +577,15 @@ export default function StrainScreen() { {climbingParsed.error?.message ?? "Failed to load climbing data."} ) : null} - {shouldShowClimbingSection ? : null} + {shouldShowClimbingSection ? ( + <> + + + + ) : null} {/* Weekly volume summary */} diff --git a/packages/mobile/app/activity/[id].tsx b/packages/mobile/app/activity/[id].tsx index 6fa1b2c0a6..9d87ddeae3 100644 --- a/packages/mobile/app/activity/[id].tsx +++ b/packages/mobile/app/activity/[id].tsx @@ -31,6 +31,7 @@ import { } from "react-native"; import { ActivityPerceivedExertion } from "../../components/ActivityPerceivedExertion"; import { ChartTitleWithTooltip } from "../../components/ChartTitleWithTooltip"; +import { HangboardingDetail } from "../../components/HangboardingDetail"; import { MuscleGroupBodyDiagram } from "../../components/MuscleGroupBodyDiagram"; import { RouteMap } from "../../components/RouteMap"; import { type ActivityExportFormat, downloadActivityExport } from "../../lib/activity-export"; @@ -53,6 +54,10 @@ function isClimbingActivityType(activityType: string): boolean { return activityType === "climbing"; } +function isHangboardingActivityType(activityType: string): boolean { + return activityType === "hangboard"; +} + function activityIcon(type: string): string { return getActivityIconInfo(type).emoji; } @@ -523,6 +528,9 @@ export default function ActivityDetailScreen() { const [isRecomputing, setIsRecomputing] = useState(false); const deleteMutation = trpc.activity.delete.useMutation({ onSuccess: async () => { + if (id) { + await trpcUtils.activity.hangboardDetails.invalidate({ id }); + } await trpcUtils.activity.list.invalidate(); router.back(); }, @@ -540,6 +548,7 @@ export default function ActivityDetailScreen() { trpcUtils.activity.hrZones.invalidate({ id }), trpcUtils.activity.powerZones.invalidate({ id }), trpcUtils.activity.strengthExercises.invalidate({ id }), + trpcUtils.activity.hangboardDetails.invalidate({ id }), trpcUtils.activity.list.invalidate(), trpcUtils.calendar.weekList.invalidate(), trpcUtils.calendar.activityOverview.invalidate(), @@ -606,6 +615,12 @@ export default function ActivityDetailScreen() { { id: id ?? "" }, { enabled: !!id && isClimbingActivity }, ); + const isHangboardingActivity = + detail.data != null && isHangboardingActivityType(detail.data.activityType); + const hangboardDetails = trpc.activity.hangboardDetails.useQuery( + { id: id ?? "" }, + { enabled: !!id && isHangboardingActivity }, + ); const [hoveredPosition, setHoveredPosition] = useState<{ lat: number; lng: number } | null>(null); const [scrollEnabled, setScrollEnabled] = useState(true); @@ -822,6 +837,17 @@ export default function ActivityDetailScreen() { {stats.length > 0 && } + {isHangboardingActivity && ( + + Hangboarding + + + )} + {/* Route Map */} {hasGps && } @@ -1021,3 +1047,8 @@ export default function ActivityDetailScreen() { ); } + +const hangboardingStyles = StyleSheet.create({ + container: { gap: 12 }, + title: { color: colors.text, fontSize: 18, fontWeight: "700" }, +}); diff --git a/packages/mobile/components/HangboardingDetail.stories.tsx b/packages/mobile/components/HangboardingDetail.stories.tsx new file mode 100644 index 0000000000..37c45d95fa --- /dev/null +++ b/packages/mobile/components/HangboardingDetail.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import type { HangboardingDetail as HangboardingDetailData } from "../../server/src/repositories/hangboarding-repository.ts"; +import { HangboardingDetail } from "./HangboardingDetail"; + +const data: HangboardingDetailData = { + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + { + id: "interval-2", + intervalIndex: 1, + label: "Rest", + intervalType: "rest", + startedAt: "2026-08-07T14:00:07.000Z", + endedAt: "2026-08-07T14:00:53.000Z", + durationSeconds: 46, + }, + ], +}; + +const meta = { + title: "Components/HangboardingDetail", + component: HangboardingDetail, + args: { data, loading: false, error: null }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; +export const SegmentWarning: Story = { + args: { data: { ...data, segmentsError: "Some intervals had incomplete timestamps." } }, +}; +export const Loading: Story = { args: { data: undefined, loading: true, error: null } }; +export const ErrorState: Story = { + args: { data: undefined, loading: false, error: new Error("Hangboarding details unavailable") }, +}; diff --git a/packages/mobile/components/HangboardingDetail.test.tsx b/packages/mobile/components/HangboardingDetail.test.tsx new file mode 100644 index 0000000000..a5c7fd46a2 --- /dev/null +++ b/packages/mobile/components/HangboardingDetail.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { HangboardingDetail as HangboardingDetailData } from "../../server/src/repositories/hangboarding-repository.ts"; + +import { HangboardingDetail } from "./HangboardingDetail"; + +const detail: HangboardingDetailData = { + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + { + id: "interval-2", + intervalIndex: 1, + label: "Rest", + intervalType: "rest", + startedAt: "2026-08-07T14:00:07.000Z", + endedAt: "2026-08-07T14:00:53.000Z", + durationSeconds: 46, + }, + ], +}; + +describe("HangboardingDetail", () => { + it("renders plan and board metadata plus interval labels, types, timestamps, and durations", () => { + render(); + + expect(screen.getByText("Plan")).toBeTruthy(); + expect(screen.getByText("7/3 Repeaters")).toBeTruthy(); + expect(screen.getByText("Session")).toBeTruthy(); + expect(screen.getByText("session-1")).toBeTruthy(); + expect(screen.getByText("Board")).toBeTruthy(); + expect(screen.getByText("Tension Board")).toBeTruthy(); + expect(screen.getByText("Board ID")).toBeTruthy(); + expect(screen.getByText("board-1")).toBeTruthy(); + expect( + screen.getAllByTestId("hangboarding-interval-label").map((node) => node.textContent), + ).toEqual(["Step 1: 19 mm edge", "Rest"]); + expect(screen.getByText("Work")).toBeTruthy(); + expect(screen.getAllByText("Rest")).toHaveLength(2); + expect(screen.getAllByText(/2026/).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("7s")).toBeTruthy(); + expect(screen.getByText("46s")).toBeTruthy(); + }); + + it("renders empty metadata and nullable interval fields as em dashes", () => { + render( + , + ); + + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(8); + }); + + it("preserves valid intervals while showing an actionable import warning", () => { + render( + , + ); + + expect(screen.getByRole("alert").textContent).toContain("Segment 3 had no end timestamp"); + expect(screen.getByText("Step 1: 19 mm edge")).toBeTruthy(); + }); + + it("uses explicit loading and preserves the server error message", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("query-state-loading")).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByTestId("query-state-error")).toBeTruthy(); + expect(screen.getByText("Hangboarding details unavailable")).toBeTruthy(); + }); +}); diff --git a/packages/mobile/components/HangboardingDetail.tsx b/packages/mobile/components/HangboardingDetail.tsx new file mode 100644 index 0000000000..dadc000945 --- /dev/null +++ b/packages/mobile/components/HangboardingDetail.tsx @@ -0,0 +1,149 @@ +import { formatDateTime, formatDurationSeconds } from "@dofek/format/format"; +import { StyleSheet, Text, View } from "react-native"; +import type { HangboardingDetail as HangboardingDetailData } from "../../server/src/repositories/hangboarding-repository.ts"; +import { colors, spacing } from "../theme"; +import { getQueryErrorMessage, QueryStatePanel } from "./QueryStatePanel"; + +interface HangboardingDetailProps { + data: HangboardingDetailData | undefined; + loading: boolean; + error: unknown; +} + +function nullableValue(value: string | null): string { + return value ?? "—"; +} + +function intervalTypeLabel(value: "work" | "rest" | null): string { + if (value == null) return "—"; + return value === "work" ? "Work" : "Rest"; +} + +export function HangboardingDetail({ data, loading, error }: HangboardingDetailProps) { + if (data == null && loading) { + return ; + } + + if (data == null && error) { + return ( + + ); + } + + if (data == null) { + return ( + + ); + } + + return ( + + {error ? ( + + ) : null} + {data.segmentsError ? ( + + + Some Hangboarding intervals could not be imported: {data.segmentsError} Re-import the + activity to try again. + + + ) : null} + + + + + + + + + {data.intervals.length === 0 ? ( + + ) : ( + + {data.intervals.map((interval) => ( + + + + {nullableValue(interval.label)} + + {intervalTypeLabel(interval.intervalType)} + + + {formatDateTime(interval.startedAt)} + + {interval.endedAt == null ? "—" : formatDateTime(interval.endedAt)} + + + {interval.durationSeconds == null + ? "—" + : formatDurationSeconds(interval.durationSeconds)} + + + + ))} + + )} + + ); +} + +function Metadata({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { gap: spacing.md }, + warning: { + backgroundColor: colors.surfaceSecondary, + borderColor: colors.warning, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + padding: spacing.md, + }, + warningText: { color: colors.warning, fontSize: 13, lineHeight: 18 }, + metadataGrid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.md }, + metadataItem: { gap: spacing.xs, minWidth: "46%", flexGrow: 1 }, + metadataLabel: { + color: colors.textTertiary, + fontSize: 11, + letterSpacing: 0.3, + textTransform: "uppercase", + }, + metadataValue: { color: colors.text, fontSize: 14 }, + intervalList: { + backgroundColor: colors.surface, + borderRadius: 12, + overflow: "hidden", + }, + intervalRow: { + borderBottomColor: colors.surfaceSecondary, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: spacing.sm, + padding: spacing.md, + }, + intervalHeader: { flexDirection: "row", gap: spacing.sm, justifyContent: "space-between" }, + intervalLabel: { color: colors.text, flex: 1, fontSize: 14, fontWeight: "600" }, + intervalType: { color: colors.textSecondary, fontSize: 12, textTransform: "uppercase" }, + intervalMetadata: { flexDirection: "row", gap: spacing.sm, justifyContent: "space-between" }, + intervalTimestamp: { color: colors.textTertiary, flex: 1, fontSize: 11 }, + intervalDuration: { + color: colors.text, + fontSize: 13, + fontVariant: ["tabular-nums"], + fontWeight: "600", + }, +}); diff --git a/packages/mobile/components/HangboardingSummary.stories.tsx b/packages/mobile/components/HangboardingSummary.stories.tsx new file mode 100644 index 0000000000..d02bc9e2cd --- /dev/null +++ b/packages/mobile/components/HangboardingSummary.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../server/src/repositories/hangboarding-repository.ts"; +import { HangboardingSummary } from "./HangboardingSummary"; + +const data: HangboardingSummaryData = { + sessionCount: 4, + totalDurationSeconds: 3120, + averageDurationSeconds: 780, + totalWorkDurationSeconds: 240, + totalRestDurationSeconds: 1440, + workIntervalCount: 24, + averageHeartRate: 126, + peakHeartRate: 154, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "7/3 Repeaters", + boardName: "Tension Board", + durationSeconds: 840, + }, + daily: [ + { + date: "2026-08-05", + sessionCount: 1, + durationSeconds: 720, + workDurationSeconds: 60, + restDurationSeconds: 360, + }, + { + date: "2026-08-07", + sessionCount: 2, + durationSeconds: 1560, + workDurationSeconds: 120, + restDurationSeconds: 720, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 840, + workDurationSeconds: 60, + restDurationSeconds: 360, + }, + ], +}; + +const meta = { + title: "Components/HangboardingSummary", + component: HangboardingSummary, + args: { data, loading: false }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; +export const Loading: Story = { args: { data: undefined, loading: true } }; +export const Empty: Story = { + args: { data: { ...data, sessionCount: 0, daily: [], latestSession: null }, loading: false }, +}; diff --git a/packages/mobile/components/HangboardingSummary.test.tsx b/packages/mobile/components/HangboardingSummary.test.tsx new file mode 100644 index 0000000000..19af97019d --- /dev/null +++ b/packages/mobile/components/HangboardingSummary.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../server/src/repositories/hangboarding-repository.ts"; + +const sparkLineProps = vi.hoisted(() => { + const props: Array> = []; + return props; +}); + +vi.mock("./charts/SparkLine", () => ({ + SparkLine: (props: Record) => { + sparkLineProps.push(props); + return null; + }, +})); + +import { HangboardingSummary } from "./HangboardingSummary"; + +const summary: HangboardingSummaryData = { + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "Repeaters", + boardName: "Tension Board", + durationSeconds: 900, + }, + daily: [ + { + date: "2026-08-07", + sessionCount: 1, + durationSeconds: 600, + workDurationSeconds: 7, + restDurationSeconds: 53, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 900, + workDurationSeconds: 10, + restDurationSeconds: 50, + }, + ], +}; + +afterEach(() => { + sparkLineProps.length = 0; +}); + +describe("HangboardingSummary", () => { + it("renders server-provided metrics, latest metadata, and daily duration values", () => { + render(); + + for (const label of [ + "Sessions", + "Total Time", + "Avg Session", + "Work Time", + "Rest Time", + "Work Intervals", + "Avg Heart Rate", + "Peak Heart Rate", + ]) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.getByText("25m")).toBeTruthy(); + expect(screen.getByText("13m")).toBeTruthy(); + expect(screen.getByText("17s")).toBeTruthy(); + expect(screen.getByText("2m")).toBeTruthy(); + expect(screen.getByText("125 bpm")).toBeTruthy(); + expect(screen.getByText("150 bpm")).toBeTruthy(); + expect(screen.getByText("Repeaters")).toBeTruthy(); + expect(screen.getByText("Tension Board")).toBeTruthy(); + expect(screen.getByText("15m")).toBeTruthy(); + expect(screen.getByText(/2026/)).toBeTruthy(); + expect(sparkLineProps).toHaveLength(1); + expect(sparkLineProps[0]?.data).toEqual([600, 900]); + }); + + it("renders nullable metrics and latest metadata as em dashes", () => { + render( + , + ); + + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(5); + expect(screen.queryByText("0 bpm")).toBeNull(); + }); + + it("uses explicit loading and empty states", () => { + const { rerender } = render(); + expect(screen.getByTestId("query-state-loading")).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByText("No Hangboarding sessions yet.")).toBeTruthy(); + }); +}); diff --git a/packages/mobile/components/HangboardingSummary.tsx b/packages/mobile/components/HangboardingSummary.tsx new file mode 100644 index 0000000000..0dfb77af72 --- /dev/null +++ b/packages/mobile/components/HangboardingSummary.tsx @@ -0,0 +1,147 @@ +import { formatDateTime, formatDurationSeconds, formatNumber } from "@dofek/format/format"; +import { StyleSheet, Text, View } from "react-native"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../server/src/repositories/hangboarding-repository.ts"; +import { colors, spacing } from "../theme"; +import { SparkLine } from "./charts/SparkLine"; +import { QueryStatePanel } from "./QueryStatePanel"; + +interface HangboardingSummaryProps { + data: HangboardingSummaryData | undefined; + loading: boolean; +} + +function formatNullableDuration(value: number | null): string { + return value == null ? "—" : formatDurationSeconds(value); +} + +function formatNullableHeartRate(value: number | null): string { + return value == null ? "—" : `${formatNumber(value, 0)} bpm`; +} + +export function HangboardingSummary({ data, loading }: HangboardingSummaryProps) { + if (data == null && loading) { + return ; + } + + if (data == null || data.sessionCount === 0) { + return ( + + ); + } + + const metrics = [ + { label: "Sessions", value: String(data.sessionCount) }, + { label: "Total Time", value: formatDurationSeconds(data.totalDurationSeconds) }, + { label: "Avg Session", value: formatNullableDuration(data.averageDurationSeconds) }, + { label: "Work Time", value: formatNullableDuration(data.totalWorkDurationSeconds) }, + { label: "Rest Time", value: formatNullableDuration(data.totalRestDurationSeconds) }, + { + label: "Work Intervals", + value: data.workIntervalCount == null ? "—" : String(data.workIntervalCount), + }, + { label: "Avg Heart Rate", value: formatNullableHeartRate(data.averageHeartRate) }, + { label: "Peak Heart Rate", value: formatNullableHeartRate(data.peakHeartRate) }, + ]; + + return ( + + + {metrics.map((metric) => ( + + {metric.label} + {metric.value} + + ))} + + + + Daily Duration + {data.daily.length > 0 ? ( + row.durationSeconds)} + color={colors.blue} + height={48} + /> + ) : ( + No daily Hangboarding duration + )} + + + {data.latestSession ? ( + + Latest Session + + {data.latestSession.planName ?? "Hangboarding session"} + + {data.latestSession.boardName ? ( + {data.latestSession.boardName} + ) : null} + + + Started + + {formatDateTime(data.latestSession.startedAt)} + + + + Duration + + {formatDurationSeconds(data.latestSession.durationSeconds)} + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { gap: spacing.md }, + grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, + metricCard: { + backgroundColor: colors.surface, + borderRadius: 12, + flexGrow: 1, + gap: spacing.xs, + minWidth: "46%", + padding: spacing.md, + }, + metricLabel: { + color: colors.textTertiary, + fontSize: 11, + letterSpacing: 0.3, + textTransform: "uppercase", + }, + metricValue: { + color: colors.text, + fontSize: 18, + fontVariant: ["tabular-nums"], + fontWeight: "700", + }, + trendCard: { + backgroundColor: colors.surface, + borderRadius: 12, + gap: spacing.sm, + padding: spacing.md, + }, + sectionLabel: { + color: colors.textTertiary, + fontSize: 11, + letterSpacing: 0.3, + textTransform: "uppercase", + }, + emptyText: { color: colors.textSecondary, fontSize: 13 }, + latestCard: { + backgroundColor: colors.surface, + borderRadius: 12, + gap: spacing.sm, + padding: spacing.md, + }, + latestTitle: { color: colors.text, fontSize: 15, fontWeight: "600" }, + latestBoard: { color: colors.textSecondary, fontSize: 13 }, + latestMetadata: { flexDirection: "row", gap: spacing.lg }, + latestMetadataItem: { flex: 1, gap: spacing.xs }, + metadataLabel: { color: colors.textTertiary, fontSize: 12 }, + metadataValue: { color: colors.text, fontSize: 13 }, +}); diff --git a/packages/mobile/modules/whoop-ble/package.json b/packages/mobile/modules/whoop-ble/package.json index a8b8dfa78e..a500fe10c8 100644 --- a/packages/mobile/modules/whoop-ble/package.json +++ b/packages/mobile/modules/whoop-ble/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/whoop-ble", - "version": "0.1.1", + "version": "0.1.4", "description": "Reverse-engineered WHOOP strap BLE protocol and Expo iOS client", "type": "module", "license": "MIT", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6ecb759a38..1a1993becd 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -80,7 +80,7 @@ "expo-updates": "57.0.13", "expo-web-browser": "57.0.2", "posthog-react-native": "4.61.2", - "react": "19.2.3", + "react": "19.2.8", "react-native": "0.86.2", "react-native-body-highlighter": "3.2.0", "react-native-gesture-handler": "2.32.0", @@ -98,8 +98,8 @@ "@storybook/react-native": "10.5.3", "@storybook/react-native-web-vite": "10.5.4", "@testing-library/react": "16.3.2", - "@types/react": "19.2.14", - "playwright": "1.55.1", + "@types/react": "19.2.18", + "playwright": "1.62.1", "react-dom": "19.2.8", "react-native-web": "0.21.2", "storybook": "10.5.4", diff --git a/packages/peloton-client/package.json b/packages/peloton-client/package.json index 333ae3777d..3bcf0b6363 100644 --- a/packages/peloton-client/package.json +++ b/packages/peloton-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/peloton", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Peloton API client with reverse-engineered Auth0 authentication", "type": "module", "license": "MIT", diff --git a/packages/provider-http/package.json b/packages/provider-http/package.json index ea09465a17..1e7e922fca 100644 --- a/packages/provider-http/package.json +++ b/packages/provider-http/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/provider-http", - "version": "0.1.1", + "version": "0.1.4", "description": "Rate-limit-aware Fetch utilities and typed provider HTTP errors", "type": "module", "license": "MIT", diff --git a/packages/scoring/package.json b/packages/scoring/package.json index 8c7ab490ad..c71e9cecd0 100644 --- a/packages/scoring/package.json +++ b/packages/scoring/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/scoring", - "version": "0.1.1", + "version": "0.1.4", "description": "Platform-agnostic health scoring models, labels, colors, and design tokens", "type": "module", "license": "MIT", diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index d94f952d83..fb1a8ad813 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -302,6 +302,18 @@ function validTrainingFixture(): z.input { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }, }, }; diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index b47faa5bd2..5e3880565e 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -382,6 +382,34 @@ export const mobileTrainingTabOutputSchema = z.object({ hardestRouteGradeSortValue: z.number().nullable(), }), ), + hangboarding: z.object({ + sessionCount: z.number().int().nonnegative(), + totalDurationSeconds: nonnegativeNumberSchema, + averageDurationSeconds: nonnegativeNumberSchema.nullable(), + totalWorkDurationSeconds: nonnegativeNumberSchema.nullable(), + totalRestDurationSeconds: nonnegativeNumberSchema.nullable(), + workIntervalCount: z.number().int().nonnegative().nullable(), + averageHeartRate: nonnegativeNumberSchema.nullable(), + peakHeartRate: nonnegativeNumberSchema.nullable(), + latestSession: z + .object({ + activityId: z.string(), + startedAt: z.iso.datetime(), + planName: z.string().nullable(), + boardName: z.string().nullable(), + durationSeconds: nonnegativeNumberSchema, + }) + .nullable(), + daily: z.array( + z.object({ + date: dateSchema, + sessionCount: z.number().int().nonnegative(), + durationSeconds: nonnegativeNumberSchema, + workDurationSeconds: nonnegativeNumberSchema.nullable(), + restDurationSeconds: nonnegativeNumberSchema.nullable(), + }), + ), + }), }), }); diff --git a/packages/server/src/repositories/activities-calendar-repository.test.ts b/packages/server/src/repositories/activities-calendar-repository.test.ts index b19e4131ad..004c71be23 100644 --- a/packages/server/src/repositories/activities-calendar-repository.test.ts +++ b/packages/server/src/repositories/activities-calendar-repository.test.ts @@ -808,6 +808,53 @@ describe("ActivitiesCalendarRepository", () => { }); }); + it("returns available zero measurements for an empty current period", async () => { + const database = makeDatabase([[{ id: "previous-activity" }], [{ id: "current-activity" }]]); + const sensorStore = makeSensorStore([ + [ + { + current_activity_count: 0, + current_total_minutes: 0, + current_total_distance_meters: null, + current_total_elevation_gain_m: null, + current_distance_measurement_count: 0, + current_elevation_measurement_count: 0, + previous_activity_count: 0, + previous_total_minutes: 0, + previous_total_distance_meters: null, + previous_total_elevation_gain_m: null, + previous_distance_measurement_count: 0, + previous_elevation_measurement_count: 0, + }, + ], + [], + ]); + const repository = new ActivitiesCalendarRepository(database, "user-1", "UTC", sensorStore); + + await expect( + repository.getActivityOverview({ weeks: 4, endDate: "2026-03-20" }), + ).resolves.toMatchObject({ + activityCount: 0, + totalMinutes: 0, + totalDistanceMeters: 0, + totalDistanceState: { status: "available" }, + totalElevationGainM: 0, + totalElevationState: { status: "available" }, + comparison: { + totalDistanceMeters: { + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, + }, + totalElevationGainM: { + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, + }, + }, + }); + }); + it("authors lower, unchanged, and previous-period unavailable comparisons", async () => { const database = makeDatabase([ [{ id: "current" }, { id: "previous-1" }, { id: "previous-2" }], @@ -857,7 +904,7 @@ describe("ActivitiesCalendarRepository", () => { }); }); - it("does not report partial overview totals as available", async () => { + it("reports partial overview totals as available", async () => { const database = makeDatabase([ [{ id: "run" }, { id: "ride" }], [{ id: "run" }, { id: "ride" }], @@ -887,16 +934,61 @@ describe("ActivitiesCalendarRepository", () => { repository.getActivityOverview({ weeks: 4, endDate: "2026-03-20" }), ).resolves.toMatchObject({ activityCount: 2, - totalDistanceMeters: null, - totalDistanceState: { - status: "missing", - reason: "Distance was not recorded for every activity.", - }, + totalDistanceMeters: 5000, + totalDistanceState: { status: "available" }, totalElevationGainM: 100, totalElevationState: { status: "available" }, }); }); + it("compares partial totals when both periods have measurements", async () => { + const database = makeDatabase([ + [{ id: "current-1" }, { id: "current-2" }], + [{ id: "previous-1" }, { id: "previous-2" }], + ]); + const sensorStore = makeSensorStore([ + [ + { + current_activity_count: 2, + current_total_minutes: 120, + current_total_distance_meters: 7500, + current_total_elevation_gain_m: 150, + current_distance_measurement_count: 1, + current_elevation_measurement_count: 1, + previous_activity_count: 2, + previous_total_minutes: 90, + previous_total_distance_meters: 5000, + previous_total_elevation_gain_m: 100, + previous_distance_measurement_count: 1, + previous_elevation_measurement_count: 1, + }, + ], + [{ canonical_type: "running" }], + ]); + const repository = new ActivitiesCalendarRepository(database, "user-1", "UTC", sensorStore); + + await expect( + repository.getActivityOverview({ weeks: 4, endDate: "2026-03-20" }), + ).resolves.toMatchObject({ + totalDistanceMeters: 7500, + totalDistanceState: { status: "available" }, + totalElevationGainM: 150, + totalElevationState: { status: "available" }, + comparison: { + totalDistanceMeters: { + magnitude: 2500, + trend: "higher", + state: { status: "available" }, + }, + totalElevationGainM: { + magnitude: 50, + trend: "higher", + state: { status: "available" }, + }, + }, + }); + }); + it("counts indoor and virtual zero distance as measured with outdoor totals", async () => { const database = makeDatabase([ [{ id: "indoor" }, { id: "run" }], @@ -946,24 +1038,24 @@ describe("ActivitiesCalendarRepository", () => { ).resolves.toMatchObject({ activityCount: 0, totalMinutes: 0, - totalDistanceMeters: null, - totalDistanceState: { status: "missing", reason: "Distance not recorded" }, - totalElevationGainM: null, - totalElevationState: { status: "missing", reason: "Elevation gain not recorded" }, + totalDistanceMeters: 0, + totalDistanceState: { status: "available" }, + totalElevationGainM: 0, + totalElevationState: { status: "available" }, activityTypes: [], comparison: { periodLabel: "previous 4 weeks", activityCount: { magnitude: 0, trend: "unchanged" }, totalMinutes: { magnitude: 0, trend: "unchanged" }, totalDistanceMeters: { - magnitude: null, - trend: "unavailable", - state: { status: "missing", reason: "Distance not recorded" }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, totalElevationGainM: { - magnitude: null, - trend: "unavailable", - state: { status: "missing", reason: "Elevation gain not recorded" }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, }, }); @@ -971,7 +1063,7 @@ describe("ActivitiesCalendarRepository", () => { expect(database.execute).toHaveBeenCalledTimes(1); }); - it("uses empty aggregate rows to author an unavailable comparison", async () => { + it("uses empty aggregate rows to author available zero comparisons", async () => { const database = makeDatabase([[{ id: "activity" }], [{ id: "activity" }]]); const sensorStore = makeSensorStore([[], [{ canonical_type: "running" }]]); const repository = new ActivitiesCalendarRepository(database, "user-1", "UTC", sensorStore); @@ -984,9 +1076,9 @@ describe("ActivitiesCalendarRepository", () => { comparison: { activityCount: { magnitude: 0, trend: "unchanged" }, totalDistanceMeters: { - magnitude: null, - trend: "unavailable", - state: { status: "missing", reason: "Distance not recorded" }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, }, }); @@ -1062,10 +1154,10 @@ describe("ActivitiesCalendarRepository", () => { ).resolves.toMatchObject({ activityCount: 0, totalMinutes: 0, - totalDistanceMeters: null, - totalDistanceState: { status: "missing", reason: "Distance not recorded" }, - totalElevationGainM: null, - totalElevationState: { status: "missing", reason: "Elevation gain not recorded" }, + totalDistanceMeters: 0, + totalDistanceState: { status: "available" }, + totalElevationGainM: 0, + totalElevationState: { status: "available" }, activityTypes: ["running"], }); for (const queryCall of vi.mocked(sensorStore.query).mock.calls) { diff --git a/packages/server/src/repositories/activities-calendar-repository.ts b/packages/server/src/repositories/activities-calendar-repository.ts index 4b6cda19a3..f4e78bcc54 100644 --- a/packages/server/src/repositories/activities-calendar-repository.ts +++ b/packages/server/src/repositories/activities-calendar-repository.ts @@ -767,17 +767,17 @@ function emptyActivityOverviewPeriod(): ActivityOverviewPeriod { return { activityCount: 0, totalMinutes: 0, - totalDistance: overviewMeasurement("Distance", null, true), - totalElevation: overviewMeasurement("Elevation gain", null, true), + totalDistance: overviewMeasurement("Distance", 0, true), + totalElevation: overviewMeasurement("Elevation gain", 0, true), }; } function overviewMeasurement( label: string, value: number | null, - complete: boolean, + isAvailable: boolean, ): ActivityOverviewMeasurement { - if (!complete) { + if (!isAvailable) { return { value: null, state: { @@ -818,16 +818,34 @@ function overviewPeriodFromRow( period === "current" ? row.current_elevation_measurement_count : row.previous_elevation_measurement_count; - const distanceComplete = distanceMeasurementCount === activityCount; - const elevationComplete = elevationMeasurementCount === activityCount; - const completeDistance = distanceComplete ? roundNullableMetric(totalDistanceMeters) : null; - const completeElevation = elevationComplete ? roundNullableMetric(totalElevationGainM) : null; + const distanceHasMeasurement = distanceMeasurementCount > 0; + const elevationHasMeasurement = elevationMeasurementCount > 0; + const distanceValue = + activityCount === 0 + ? 0 + : distanceHasMeasurement + ? roundNullableMetric(totalDistanceMeters) + : null; + const elevationValue = + activityCount === 0 + ? 0 + : elevationHasMeasurement + ? roundNullableMetric(totalElevationGainM) + : null; return { activityCount, totalMinutes, - totalDistance: overviewMeasurement("Distance", completeDistance, distanceComplete), - totalElevation: overviewMeasurement("Elevation gain", completeElevation, elevationComplete), + totalDistance: overviewMeasurement( + "Distance", + distanceValue, + distanceHasMeasurement || activityCount === 0, + ), + totalElevation: overviewMeasurement( + "Elevation gain", + elevationValue, + elevationHasMeasurement || activityCount === 0, + ), }; } diff --git a/packages/server/src/repositories/activity-visibility-consistency.integration.test.ts b/packages/server/src/repositories/activity-visibility-consistency.integration.test.ts index 13411f9228..3bcbdc5bf4 100644 --- a/packages/server/src/repositories/activity-visibility-consistency.integration.test.ts +++ b/packages/server/src/repositories/activity-visibility-consistency.integration.test.ts @@ -269,36 +269,24 @@ describe("activity visibility consistency", () => { expect(overview).toEqual({ activityCount: 3, totalMinutes: 90, - totalDistanceMeters: null, - totalDistanceState: { - status: "missing", - reason: "Distance was not recorded for every activity.", - }, - totalElevationGainM: null, - totalElevationState: { - status: "missing", - reason: "Elevation gain was not recorded for every activity.", - }, + totalDistanceMeters: 0, + totalDistanceState: { status: "available" }, + totalElevationGainM: 0, + totalElevationState: { status: "available" }, activityTypes: ["running", "walking"], comparison: { periodLabel: "previous 8 weeks", activityCount: { magnitude: 3, trend: "higher" }, totalMinutes: { magnitude: 90, trend: "higher" }, totalDistanceMeters: { - magnitude: null, - trend: "unavailable", - state: { - status: "missing", - reason: "Distance was not recorded for every activity.", - }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, totalElevationGainM: { - magnitude: null, - trend: "unavailable", - state: { - status: "missing", - reason: "Elevation gain was not recorded for every activity.", - }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, }, }); @@ -373,24 +361,24 @@ describe("activity visibility consistency", () => { ).resolves.toEqual({ activityCount: 0, totalMinutes: 0, - totalDistanceMeters: null, - totalDistanceState: { status: "missing", reason: "Distance not recorded" }, - totalElevationGainM: null, - totalElevationState: { status: "missing", reason: "Elevation gain not recorded" }, + totalDistanceMeters: 0, + totalDistanceState: { status: "available" }, + totalElevationGainM: 0, + totalElevationState: { status: "available" }, activityTypes: ["running", "walking"], comparison: { periodLabel: "previous 8 weeks", activityCount: { magnitude: 0, trend: "unchanged" }, totalMinutes: { magnitude: 0, trend: "unchanged" }, totalDistanceMeters: { - magnitude: null, - trend: "unavailable", - state: { status: "missing", reason: "Distance not recorded" }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, totalElevationGainM: { - magnitude: null, - trend: "unavailable", - state: { status: "missing", reason: "Elevation gain not recorded" }, + magnitude: 0, + trend: "unchanged", + state: { status: "available" }, }, }, }); diff --git a/packages/server/src/repositories/hangboarding-repository.integration.test.ts b/packages/server/src/repositories/hangboarding-repository.integration.test.ts new file mode 100644 index 0000000000..6a041d5744 --- /dev/null +++ b/packages/server/src/repositories/hangboarding-repository.integration.test.ts @@ -0,0 +1,287 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { TEST_USER_ID } from "../../../../src/db/schema/core.ts"; +import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts"; +import { HangboardingRepository } from "./hangboarding-repository.ts"; + +describe("HangboardingRepository integration", () => { + let testContext: TestContext; + let firstActivityId: string; + let nonHangboardingActivityId: string; + let groupedOtherActivityId: string; + let noHangTenActivityId: string; + let firstDate: string; + let secondDate: string; + let noHangTenDate: string; + let nullDataDate: string; + + function dateOnly(timestamp: string): string { + return new Date(timestamp).toISOString().slice(0, 10); + } + + beforeAll(async () => { + testContext = await setupTestDatabase(); + + await testContext.db.execute( + sql`INSERT INTO fitness.provider (id, name, user_id) + VALUES + ('hangboarding-repository-test', 'Hang Ten', ${TEST_USER_ID}), + ('hangboarding-repository-other', 'Other Hangboard', ${TEST_USER_ID}) + ON CONFLICT DO NOTHING`, + ); + await testContext.db.execute( + sql`INSERT INTO fitness.provider_priority (provider_id, priority) + VALUES + ('hangboarding-repository-test', 100), + ('hangboarding-repository-other', 1) + ON CONFLICT (provider_id) DO UPDATE SET priority = EXCLUDED.priority`, + ); + + const activities = await testContext.db.execute<{ + id: string; + external_id: string; + started_at: string; + }>( + sql`INSERT INTO fitness.activity ( + provider_id, user_id, external_id, canonical_type, provider_type, + started_at, ended_at, name, raw + ) VALUES + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-session-1', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '2 days', + CURRENT_TIMESTAMP - INTERVAL '2 days' + INTERVAL '10 minutes', 'Repeaters', + '{"avgHeartRate":120,"maxHeartRate":145,"hangTen":{"sessionId":"session-1","planName":"Repeaters","boardId":"board-1","boardName":"Tension Board"}}'::jsonb + ), + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-session-2', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '1 day', + CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '15 minutes', 'Max Hangs', + '{"avgHeartRate":130,"maxHeartRate":150,"hangTen":{"sessionId":"session-2","planName":"Max Hangs","boardName":"Tension Board"}}'::jsonb + ), + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-not-hangboard', + 'climbing', 'rock_climbing', CURRENT_TIMESTAMP - INTERVAL '1 day', + CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '30 minutes', 'Wall Session', '{}'::jsonb + ), + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-null-data', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '31 days', NULL, + 'Incomplete Session', '{"hangTen":{"sessionId":"session-null","planName":"Incomplete"}}'::jsonb + ), + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-no-hang-ten', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '3 days', + CURRENT_TIMESTAMP - INTERVAL '3 days' + INTERVAL '5 minutes', 'Metadata Missing', '{}'::jsonb + ) + RETURNING id::text AS id, external_id, started_at::text AS started_at`, + ); + + const firstActivity = activities.find( + (activity) => activity.external_id === "hangboard-repository-session-1", + ); + const nonHangboardingActivity = activities.find( + (activity) => activity.external_id === "hangboard-repository-not-hangboard", + ); + if (!firstActivity || !nonHangboardingActivity) { + throw new Error("Failed to seed Hangboarding repository activities"); + } + const secondActivity = activities.find( + (activity) => activity.external_id === "hangboard-repository-session-2", + ); + const nullDataActivity = activities.find( + (activity) => activity.external_id === "hangboard-repository-null-data", + ); + const noHangTenActivity = activities.find( + (activity) => activity.external_id === "hangboard-repository-no-hang-ten", + ); + if (!secondActivity || !nullDataActivity || !noHangTenActivity) { + throw new Error("Failed to seed Hangboarding repository date fixtures"); + } + firstDate = dateOnly(firstActivity.started_at); + secondDate = dateOnly(secondActivity.started_at); + nullDataDate = dateOnly(nullDataActivity.started_at); + noHangTenDate = dateOnly(noHangTenActivity.started_at); + firstActivityId = firstActivity.id; + nonHangboardingActivityId = nonHangboardingActivity.id; + noHangTenActivityId = noHangTenActivity.id; + + await testContext.db.execute( + sql`INSERT INTO fitness.activity_interval ( + activity_id, interval_index, label, interval_type, started_at, ended_at + ) VALUES + (${firstActivityId}::uuid, 0, 'Step 1: Work', 'work', + CURRENT_TIMESTAMP - INTERVAL '2 days', CURRENT_TIMESTAMP - INTERVAL '2 days' + INTERVAL '7 seconds'), + (${firstActivityId}::uuid, 1, 'Step 1: Rest', 'rest', + CURRENT_TIMESTAMP - INTERVAL '2 days' + INTERVAL '7 seconds', + CURRENT_TIMESTAMP - INTERVAL '2 days' + INTERVAL '60 seconds'), + ((SELECT id FROM fitness.activity WHERE external_id = 'hangboard-repository-session-2'), 0, + 'Step 2: Work', 'work', CURRENT_TIMESTAMP - INTERVAL '1 day', + CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '10 seconds'), + ((SELECT id FROM fitness.activity WHERE external_id = 'hangboard-repository-session-2'), 1, + 'Step 2: Rest', 'rest', CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '10 seconds', + CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '60 seconds'), + ((SELECT id FROM fitness.activity WHERE external_id = 'hangboard-repository-null-data'), 0, + 'Incomplete Work', 'work', CURRENT_TIMESTAMP - INTERVAL '31 days', NULL), + ((SELECT id FROM fitness.activity WHERE external_id = 'hangboard-repository-null-data'), 1, + 'Incomplete Rest', 'rest', CURRENT_TIMESTAMP - INTERVAL '31 days', NULL)`, + ); + + const groupedActivities = await testContext.db.execute<{ id: string; external_id: string }>( + sql`INSERT INTO fitness.activity ( + provider_id, user_id, external_id, canonical_type, provider_type, + started_at, ended_at, name, raw + ) VALUES + ( + 'hangboarding-repository-test', ${TEST_USER_ID}, 'hangboard-repository-grouped-hangten', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '40 days', + CURRENT_TIMESTAMP - INTERVAL '40 days' + INTERVAL '10 minutes', 'Grouped Hang Ten', + '{"hangTen":{"sessionId":"grouped-session","planName":"Grouped Hang Ten","boardName":"Tension Board"}}'::jsonb + ), + ( + 'hangboarding-repository-other', ${TEST_USER_ID}, 'hangboard-repository-grouped-other', + 'hangboard', 'Other Hangboard', CURRENT_TIMESTAMP - INTERVAL '40 days', + CURRENT_TIMESTAMP - INTERVAL '40 days' + INTERVAL '10 minutes', 'Grouped Other', + '{"avgHeartRate":200,"maxHeartRate":210}'::jsonb + ) + RETURNING id::text AS id, external_id`, + ); + const groupedOtherActivity = groupedActivities.find( + (activity) => activity.external_id === "hangboard-repository-grouped-other", + ); + const groupedHangTenActivity = groupedActivities.find( + (activity) => activity.external_id === "hangboard-repository-grouped-hangten", + ); + if (!groupedOtherActivity || !groupedHangTenActivity) { + throw new Error("Failed to seed grouped Hangboarding activities"); + } + groupedOtherActivityId = groupedOtherActivity.id; + await testContext.db.execute( + sql`INSERT INTO fitness.activity_interval ( + activity_id, interval_index, label, interval_type, started_at, ended_at + ) VALUES + (${groupedHangTenActivity.id}::uuid, 0, 'Hang Ten Work', 'work', + CURRENT_TIMESTAMP - INTERVAL '40 days', + CURRENT_TIMESTAMP - INTERVAL '40 days' + INTERVAL '7 seconds'), + (${groupedOtherActivity.id}::uuid, 0, 'Other Work', 'work', + CURRENT_TIMESTAMP - INTERVAL '40 days', + CURRENT_TIMESTAMP - INTERVAL '40 days' + INTERVAL '999 seconds')`, + ); + }, 60_000); + + afterAll(async () => { + await testContext?.cleanup(); + }); + + it("reads detail metadata and ordered intervals from real Postgres rows", async () => { + const repository = new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC"); + + await expect(repository.getDetail(firstActivityId)).resolves.toMatchObject({ + planName: "Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + intervals: [ + expect.objectContaining({ intervalIndex: 0, intervalType: "work", durationSeconds: 7 }), + expect.objectContaining({ intervalIndex: 1, intervalType: "rest", durationSeconds: 53 }), + ], + }); + }); + + it("computes exact session, interval, heart-rate, latest-session, and daily totals", async () => { + const repository = new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC"); + + await expect(repository.getSummary(30)).resolves.toMatchObject({ + sessionCount: 3, + totalDurationSeconds: 1800, + averageDurationSeconds: 600, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: expect.objectContaining({ + planName: "Max Hangs", + boardName: "Tension Board", + durationSeconds: 900, + }), + daily: [ + expect.objectContaining({ date: noHangTenDate, durationSeconds: 300 }), + expect.objectContaining({ date: firstDate, durationSeconds: 600 }), + expect.objectContaining({ date: secondDate, durationSeconds: 900 }), + ], + }); + }); + + it("keeps canonical hangboard details visible when Hang Ten metadata is absent", async () => { + const repository = new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC"); + + await expect(repository.getDetail(noHangTenActivityId)).resolves.toEqual({ + planName: null, + sessionId: null, + boardId: null, + boardName: null, + segmentsError: null, + intervals: [], + }); + }); + + it("returns null summary metrics for a real session with unfinished intervals and no HR", async () => { + const nextDate = new Date(`${nullDataDate}T00:00:00.000Z`); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const repository = new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC", { + kind: "limited", + paid: false, + reason: "free_signup_week", + startDate: nullDataDate, + endDateExclusive: nextDate.toISOString().slice(0, 10), + }); + + await expect(repository.getSummary(365)).resolves.toMatchObject({ + sessionCount: 1, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: expect.objectContaining({ + planName: "Incomplete", + durationSeconds: 0, + }), + daily: [ + expect.objectContaining({ + date: nullDataDate, + durationSeconds: 0, + workDurationSeconds: null, + restDurationSeconds: null, + }), + ], + }); + }); + + it("uses Hang Ten metadata and intervals when another member is canonical", async () => { + const repository = new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC"); + + await expect(repository.getDetail(groupedOtherActivityId)).resolves.toMatchObject({ + planName: "Grouped Hang Ten", + boardName: "Tension Board", + intervals: [expect.objectContaining({ label: "Hang Ten Work", durationSeconds: 7 })], + }); + }); + + it("rejects a non-owned activity and a non-Hangboarding activity", async () => { + await expect( + new HangboardingRepository( + testContext.db, + "00000000-0000-0000-0000-000000000002", + "UTC", + ).getDetail(firstActivityId), + ).resolves.toBeNull(); + await expect( + new HangboardingRepository(testContext.db, TEST_USER_ID, "UTC").getDetail( + nonHangboardingActivityId, + ), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/server/src/repositories/hangboarding-repository.test.ts b/packages/server/src/repositories/hangboarding-repository.test.ts new file mode 100644 index 0000000000..b34b120762 --- /dev/null +++ b/packages/server/src/repositories/hangboarding-repository.test.ts @@ -0,0 +1,337 @@ +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it, vi } from "vitest"; +import { HangboardingRepository } from "./hangboarding-repository.ts"; + +function makeDb(responses: Record[][]) { + const execute = vi.fn(); + for (const response of responses) execute.mockResolvedValueOnce(response); + execute.mockResolvedValue([]); + return { execute }; +} + +function makeDetailRow(overrides: Record = {}) { + return { + activity_id: "activity-1", + canonical_type: "hangboard", + plan_name: "7/3 Repeaters", + session_id: "session-1", + board_id: "board-1", + board_name: "Tension Board", + segments_error: null, + interval_id: "interval-1", + interval_index: 0, + label: "Step 1: 19 mm edge", + interval_type: "work", + interval_started_at: "2026-08-07T14:00:00.000Z", + interval_ended_at: "2026-08-07T14:00:07.000Z", + duration_seconds: 7, + ...overrides, + }; +} + +function queryText(db: ReturnType, callIndex = 0) { + const query = db.execute.mock.calls[callIndex]?.[0]; + return new PgDialect().sqlToQuery(query); +} + +describe("HangboardingRepository", () => { + it("maps Hang Ten detail metadata and orders intervals by interval index", async () => { + const db = makeDb([ + [ + { + activity_id: "activity-1", + canonical_type: "hangboard", + plan_name: "7/3 Repeaters", + session_id: "session-1", + board_id: "board-1", + board_name: "Tension Board", + segments_error: null, + interval_id: "interval-2", + interval_index: 1, + label: "Step 1: Rest", + interval_type: "rest", + interval_started_at: "2026-08-07T14:00:07.000Z", + interval_ended_at: "2026-08-07T14:01:00.000Z", + duration_seconds: 53, + }, + { + activity_id: "activity-1", + canonical_type: "hangboard", + plan_name: "7/3 Repeaters", + session_id: "session-1", + board_id: "board-1", + board_name: "Tension Board", + segments_error: null, + interval_id: "interval-1", + interval_index: 0, + label: "Step 1: 19 mm edge", + interval_type: "work", + interval_started_at: "2026-08-07T14:00:00.000Z", + interval_ended_at: "2026-08-07T14:00:07.000Z", + duration_seconds: 7, + }, + ], + ]); + + await expect( + new HangboardingRepository(db, "user-1", "UTC").getDetail("activity-1"), + ).resolves.toEqual({ + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + { + id: "interval-2", + intervalIndex: 1, + label: "Step 1: Rest", + intervalType: "rest", + startedAt: "2026-08-07T14:00:07.000Z", + endedAt: "2026-08-07T14:01:00.000Z", + durationSeconds: 53, + }, + ], + }); + }); + + it("returns null for an activity that is not owned or not Hangboarding", async () => { + const db = makeDb([]); + const repository = new HangboardingRepository(db, "user-1", "UTC"); + + await expect(repository.getDetail("not-visible")).resolves.toBeNull(); + }); + + it("applies the limited access window to detail queries", async () => { + const db = makeDb([]); + const accessWindow = { + kind: "limited", + paid: false, + reason: "free_signup_week", + startDate: "2026-08-01", + endDateExclusive: "2026-08-08", + } as const; + + await expect( + new HangboardingRepository(db, "user-1", "America/Los_Angeles", accessWindow).getDetail( + "activity-1", + ), + ).resolves.toBeNull(); + + const query = queryText(db); + expect(query.sql).toContain("CAST($3::date AS timestamp without time zone)"); + expect(query.params).toContain("2026-08-01"); + expect(query.params).toContain("2026-08-08"); + }); + + it("returns null when the query returns a non-Hangboarding activity", async () => { + const db = makeDb([[makeDetailRow({ canonical_type: "climbing" })]]); + + await expect( + new HangboardingRepository(db, "user-1", "UTC").getDetail("activity-1"), + ).resolves.toBeNull(); + }); + + it("skips detail rows without complete interval identity or timestamps", async () => { + const db = makeDb([ + [ + makeDetailRow({ interval_id: null }), + makeDetailRow({ interval_index: null }), + makeDetailRow({ interval_started_at: null }), + makeDetailRow({ interval_id: "interval-valid", interval_index: 3 }), + ], + ]); + + await expect( + new HangboardingRepository(db, "user-1", "UTC").getDetail("activity-1"), + ).resolves.toMatchObject({ + intervals: [expect.objectContaining({ id: "interval-valid", intervalIndex: 3 })], + }); + }); + + it("returns server-computed summary totals and daily rows", async () => { + const db = makeDb([ + [ + { + session_count: 2, + total_duration_seconds: 1500, + average_duration_seconds: 750, + total_work_duration_seconds: 17, + total_rest_duration_seconds: 103, + work_interval_count: 2, + average_heart_rate: 125, + peak_heart_rate: 150, + latest_activity_id: "activity-2", + latest_started_at: "2026-08-08T14:00:00.000Z", + latest_plan_name: "Repeaters", + latest_board_name: "Tension Board", + latest_duration_seconds: 900, + }, + ], + [ + { + date: "2026-08-07", + session_count: 1, + duration_seconds: 600, + work_duration_seconds: 7, + rest_duration_seconds: 53, + }, + { + date: "2026-08-08", + session_count: 1, + duration_seconds: 900, + work_duration_seconds: 10, + rest_duration_seconds: 50, + }, + ], + ]); + + await expect(new HangboardingRepository(db, "user-1", "UTC").getSummary(30)).resolves.toEqual({ + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "Repeaters", + boardName: "Tension Board", + durationSeconds: 900, + }, + daily: [ + { + date: "2026-08-07", + sessionCount: 1, + durationSeconds: 600, + workDurationSeconds: 7, + restDurationSeconds: 53, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 900, + workDurationSeconds: 10, + restDurationSeconds: 50, + }, + ], + }); + }); + + it("preserves null aggregates when interval durations or heart rates are unavailable", async () => { + const db = makeDb([ + [ + { + session_count: 1, + total_duration_seconds: 600, + average_duration_seconds: 600, + total_work_duration_seconds: null, + total_rest_duration_seconds: null, + work_interval_count: null, + average_heart_rate: null, + peak_heart_rate: null, + latest_activity_id: "activity-1", + latest_started_at: "2026-08-07T14:00:00.000Z", + latest_plan_name: null, + latest_board_name: null, + latest_duration_seconds: 600, + }, + ], + [ + { + date: "2026-08-07", + session_count: 1, + duration_seconds: 600, + work_duration_seconds: null, + rest_duration_seconds: null, + }, + ], + ]); + + const result = await new HangboardingRepository(db, "user-1", "UTC").getSummary(30); + expect(result.totalWorkDurationSeconds).toBeNull(); + expect(result.totalRestDurationSeconds).toBeNull(); + expect(result.workIntervalCount).toBeNull(); + expect(result.averageHeartRate).toBeNull(); + expect(result.peakHeartRate).toBeNull(); + expect(result.daily[0]).toMatchObject({ + workDurationSeconds: null, + restDurationSeconds: null, + }); + }); + + it("maps daily rows when no sessions are available", async () => { + const db = makeDb([ + [], + [ + { + date: "2026-08-07", + session_count: 0, + duration_seconds: 0, + work_duration_seconds: null, + rest_duration_seconds: null, + }, + ], + ]); + + await expect(new HangboardingRepository(db, "user-1", "UTC").getSummary(30)).resolves.toEqual({ + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [ + { + date: "2026-08-07", + sessionCount: 0, + durationSeconds: 0, + workDurationSeconds: null, + restDurationSeconds: null, + }, + ], + }); + }); + + it("returns no latest session when latest activity metadata is incomplete", async () => { + const db = makeDb([ + [ + { + session_count: 1, + total_duration_seconds: 600, + average_duration_seconds: 600, + total_work_duration_seconds: null, + total_rest_duration_seconds: null, + work_interval_count: null, + average_heart_rate: null, + peak_heart_rate: null, + latest_activity_id: null, + latest_started_at: null, + latest_plan_name: null, + latest_board_name: null, + latest_duration_seconds: null, + }, + ], + [], + ]); + + await expect( + new HangboardingRepository(db, "user-1", "UTC").getSummary(30), + ).resolves.toMatchObject({ latestSession: null }); + }); +}); diff --git a/packages/server/src/repositories/hangboarding-repository.ts b/packages/server/src/repositories/hangboarding-repository.ts new file mode 100644 index 0000000000..5977529077 --- /dev/null +++ b/packages/server/src/repositories/hangboarding-repository.ts @@ -0,0 +1,384 @@ +import type { Database } from "dofek/db"; +import { type SQL, sql } from "drizzle-orm"; +import { z } from "zod"; +import type { AccessWindow } from "../billing/entitlement.ts"; +import { dateStringSchema, executeWithSchema, timestampStringSchema } from "../lib/typed-sql.ts"; + +export interface HangboardingIntervalDetail { + id: string; + intervalIndex: number; + label: string | null; + intervalType: "work" | "rest" | null; + startedAt: string; + endedAt: string | null; + durationSeconds: number | null; +} + +export interface HangboardingDetail { + planName: string | null; + sessionId: string | null; + boardId: string | null; + boardName: string | null; + segmentsError: string | null; + intervals: HangboardingIntervalDetail[]; +} + +export interface HangboardingSummary { + sessionCount: number; + totalDurationSeconds: number; + averageDurationSeconds: number | null; + totalWorkDurationSeconds: number | null; + totalRestDurationSeconds: number | null; + workIntervalCount: number | null; + averageHeartRate: number | null; + peakHeartRate: number | null; + latestSession: { + activityId: string; + startedAt: string; + planName: string | null; + boardName: string | null; + durationSeconds: number; + } | null; + daily: Array<{ + date: string; + sessionCount: number; + durationSeconds: number; + workDurationSeconds: number | null; + restDurationSeconds: number | null; + }>; +} + +const detailRowSchema = z.object({ + activity_id: z.string(), + canonical_type: z.string(), + plan_name: z.string().nullable(), + session_id: z.string().nullable(), + board_id: z.string().nullable(), + board_name: z.string().nullable(), + segments_error: z.string().nullable(), + interval_id: z.string().nullable(), + interval_index: z.coerce.number().int().nullable(), + label: z.string().nullable(), + interval_type: z.enum(["work", "rest"]).nullable(), + interval_started_at: timestampStringSchema.nullable(), + interval_ended_at: timestampStringSchema.nullable(), + duration_seconds: z.coerce.number().nullable(), +}); + +const summaryRowSchema = z.object({ + session_count: z.coerce.number().int().nonnegative(), + total_duration_seconds: z.coerce.number().nonnegative(), + average_duration_seconds: z.coerce.number().nullable(), + total_work_duration_seconds: z.coerce.number().nullable(), + total_rest_duration_seconds: z.coerce.number().nullable(), + work_interval_count: z.coerce.number().int().nullable(), + average_heart_rate: z.coerce.number().nullable(), + peak_heart_rate: z.coerce.number().nullable(), + latest_activity_id: z.string().nullable(), + latest_started_at: timestampStringSchema.nullable(), + latest_plan_name: z.string().nullable(), + latest_board_name: z.string().nullable(), + latest_duration_seconds: z.coerce.number().nullable(), +}); + +const dailyRowSchema = z.object({ + date: dateStringSchema, + session_count: z.coerce.number().int().nonnegative(), + duration_seconds: z.coerce.number().nonnegative(), + work_duration_seconds: z.coerce.number().nullable(), + rest_duration_seconds: z.coerce.number().nullable(), +}); + +const memberSource = sql` + LEFT JOIN LATERAL ( + SELECT member.id AS hang_ten_activity_id, member.started_at, member.ended_at, member.raw + FROM fitness.activity AS member + WHERE member.id = ANY(a.member_activity_ids) + AND member.raw ? 'hangTen' + ORDER BY (member.id = a.id) DESC, member.created_at DESC + LIMIT 1 + ) AS member ON TRUE +`; + +export class HangboardingRepository { + readonly #db: Pick; + readonly #userId: string; + readonly #timezone: string; + readonly #accessWindow: AccessWindow; + + constructor( + database: Pick, + userId: string, + timezone: string, + accessWindow?: AccessWindow, + ) { + this.#db = database; + this.#userId = userId; + this.#timezone = timezone; + this.#accessWindow = accessWindow ?? { kind: "full", paid: true, reason: "paid_grant" }; + } + + #query(schema: TSchema, query: SQL): Promise[]> { + return executeWithSchema(this.#db, schema, query); + } + + #timestampAccessPredicate(column: SQL): SQL { + if (this.#accessWindow.kind === "full") return sql``; + return sql`AND ${column} >= (CAST(${this.#accessWindow.startDate}::date AS timestamp without time zone) AT TIME ZONE ${this.#timezone}) + AND ${column} < (CAST(${this.#accessWindow.endDateExclusive}::date AS timestamp without time zone) AT TIME ZONE ${this.#timezone})`; + } + + async getDetail(activityId: string): Promise { + const rows = await this.#query( + detailRowSchema, + sql`SELECT + a.id::text AS activity_id, + a.canonical_type::text AS canonical_type, + NULLIF(member.raw->'hangTen'->>'planName', '') AS plan_name, + NULLIF(member.raw->'hangTen'->>'sessionId', '') AS session_id, + NULLIF(member.raw->'hangTen'->>'boardId', '') AS board_id, + NULLIF(member.raw->'hangTen'->>'boardName', '') AS board_name, + NULLIF(member.raw->'hangTen'->>'activitySegmentsError', '') AS segments_error, + interval.id::text AS interval_id, + interval.interval_index, + interval.label, + CASE + WHEN interval.interval_type IN ('work', 'rest') THEN interval.interval_type + ELSE NULL + END AS interval_type, + interval.started_at::text AS interval_started_at, + interval.ended_at::text AS interval_ended_at, + CASE + WHEN interval.ended_at IS NOT NULL + THEN EXTRACT(EPOCH FROM (interval.ended_at - interval.started_at)) + ELSE NULL + END AS duration_seconds + FROM fitness.v_activity AS a + ${memberSource} + LEFT JOIN fitness.activity_interval AS interval + ON interval.activity_id = member.hang_ten_activity_id + WHERE a.user_id = ${this.#userId}::uuid + AND a.canonical_type = 'hangboard' + AND ${activityId}::uuid = ANY(a.member_activity_ids) + ${this.#timestampAccessPredicate(sql`a.started_at`)} + ORDER BY interval.interval_index NULLS LAST, interval.id`, + ); + + const firstRow = rows[0]; + if (!firstRow || firstRow.canonical_type !== "hangboard") return null; + + return { + planName: firstRow.plan_name, + sessionId: firstRow.session_id, + boardId: firstRow.board_id, + boardName: firstRow.board_name, + segmentsError: firstRow.segments_error, + intervals: [...rows] + .sort( + (left, right) => + (left.interval_index ?? Number.MAX_SAFE_INTEGER) - + (right.interval_index ?? Number.MAX_SAFE_INTEGER), + ) + .flatMap((row) => { + if ( + row.interval_id === null || + row.interval_index === null || + row.interval_started_at === null + ) { + return []; + } + return [ + { + id: row.interval_id, + intervalIndex: row.interval_index, + label: row.label, + intervalType: row.interval_type, + startedAt: row.interval_started_at, + endedAt: row.interval_ended_at, + durationSeconds: row.duration_seconds, + }, + ]; + }), + }; + } + + async getSummary(days: number): Promise { + const summaryRows = await this.#query( + summaryRowSchema, + sql`WITH sessions AS ( + SELECT + a.id::text AS activity_id, + member.hang_ten_activity_id, + CASE + WHEN member.hang_ten_activity_id IS NULL THEN a.started_at + ELSE member.started_at + END AS started_at, + CASE + WHEN member.hang_ten_activity_id IS NULL THEN a.ended_at + ELSE member.ended_at + END AS ended_at, + NULLIF(member.raw->'hangTen'->>'planName', '') AS plan_name, + NULLIF(member.raw->'hangTen'->>'boardName', '') AS board_name, + CASE + WHEN member.raw->>'avgHeartRate' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (member.raw->>'avgHeartRate')::double precision + ELSE NULL + END AS average_heart_rate, + CASE + WHEN member.raw->>'maxHeartRate' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (member.raw->>'maxHeartRate')::double precision + ELSE NULL + END AS peak_heart_rate + FROM fitness.v_activity AS a + ${memberSource} + WHERE a.user_id = ${this.#userId}::uuid + AND a.canonical_type = 'hangboard' + AND a.started_at >= NOW() - make_interval(days => ${days}::int) + ${this.#timestampAccessPredicate(sql`a.started_at`)} + ), interval_totals AS ( + SELECT + sessions.activity_id, + SUM(EXTRACT(EPOCH FROM (interval.ended_at - interval.started_at))) + FILTER (WHERE interval.interval_type = 'work' AND interval.ended_at IS NOT NULL) + AS work_duration_seconds, + SUM(EXTRACT(EPOCH FROM (interval.ended_at - interval.started_at))) + FILTER (WHERE interval.interval_type = 'rest' AND interval.ended_at IS NOT NULL) + AS rest_duration_seconds, + NULLIF(COUNT(*) FILTER ( + WHERE interval.interval_type = 'work' AND interval.ended_at IS NOT NULL + ), 0)::int AS work_interval_count + FROM sessions + LEFT JOIN fitness.activity_interval AS interval + ON interval.activity_id = sessions.hang_ten_activity_id + GROUP BY sessions.activity_id + ), session_metrics AS ( + SELECT + sessions.*, + EXTRACT(EPOCH FROM (sessions.ended_at - sessions.started_at)) AS duration_seconds, + interval_totals.work_duration_seconds, + interval_totals.rest_duration_seconds, + interval_totals.work_interval_count + FROM sessions + LEFT JOIN interval_totals ON interval_totals.activity_id = sessions.activity_id + ), aggregate AS ( + SELECT + COUNT(*)::int AS session_count, + COALESCE(SUM(duration_seconds), 0)::double precision AS total_duration_seconds, + AVG(duration_seconds)::double precision AS average_duration_seconds, + SUM(work_duration_seconds)::double precision AS total_work_duration_seconds, + SUM(rest_duration_seconds)::double precision AS total_rest_duration_seconds, + SUM(work_interval_count)::int AS work_interval_count, + AVG(average_heart_rate)::double precision AS average_heart_rate, + MAX(peak_heart_rate)::double precision AS peak_heart_rate + FROM session_metrics + ), latest AS ( + SELECT + activity_id AS latest_activity_id, + started_at AS latest_started_at, + plan_name AS latest_plan_name, + board_name AS latest_board_name, + COALESCE(duration_seconds, 0)::double precision AS latest_duration_seconds + FROM session_metrics + ORDER BY started_at DESC + LIMIT 1 + ) + SELECT aggregate.*, latest.* + FROM aggregate + LEFT JOIN latest ON TRUE`, + ); + + const dailyRows = await this.#query( + dailyRowSchema, + sql`WITH sessions AS ( + SELECT + member.hang_ten_activity_id, + CASE + WHEN member.hang_ten_activity_id IS NULL THEN a.started_at + ELSE member.started_at + END AS started_at, + CASE + WHEN member.hang_ten_activity_id IS NULL THEN a.ended_at + ELSE member.ended_at + END AS ended_at + FROM fitness.v_activity AS a + ${memberSource} + WHERE a.user_id = ${this.#userId}::uuid + AND a.canonical_type = 'hangboard' + AND a.started_at >= NOW() - make_interval(days => ${days}::int) + ${this.#timestampAccessPredicate(sql`a.started_at`)} + ), session_metrics AS ( + SELECT + (sessions.started_at AT TIME ZONE ${this.#timezone})::date AS local_date, + sessions.started_at, + EXTRACT(EPOCH FROM (sessions.ended_at - sessions.started_at)) AS duration_seconds, + SUM(EXTRACT(EPOCH FROM (interval.ended_at - interval.started_at))) + FILTER (WHERE interval.interval_type = 'work' AND interval.ended_at IS NOT NULL) + AS work_duration_seconds, + SUM(EXTRACT(EPOCH FROM (interval.ended_at - interval.started_at))) + FILTER (WHERE interval.interval_type = 'rest' AND interval.ended_at IS NOT NULL) + AS rest_duration_seconds + FROM sessions + LEFT JOIN fitness.activity_interval AS interval + ON interval.activity_id = sessions.hang_ten_activity_id + GROUP BY sessions.started_at, sessions.ended_at, sessions.hang_ten_activity_id + ) + SELECT + local_date AS date, + COUNT(*)::int AS session_count, + COALESCE(SUM(duration_seconds), 0)::double precision AS duration_seconds, + SUM(work_duration_seconds)::double precision AS work_duration_seconds, + SUM(rest_duration_seconds)::double precision AS rest_duration_seconds + FROM session_metrics + GROUP BY local_date + ORDER BY date`, + ); + + const summary = summaryRows[0]; + if (!summary) { + return { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: dailyRows.map((row) => this.#toDaily(row)), + }; + } + + return { + sessionCount: summary.session_count, + totalDurationSeconds: summary.total_duration_seconds, + averageDurationSeconds: summary.average_duration_seconds, + totalWorkDurationSeconds: summary.total_work_duration_seconds, + totalRestDurationSeconds: summary.total_rest_duration_seconds, + workIntervalCount: summary.work_interval_count, + averageHeartRate: summary.average_heart_rate, + peakHeartRate: summary.peak_heart_rate, + latestSession: + summary.latest_activity_id && summary.latest_started_at + ? { + activityId: summary.latest_activity_id, + startedAt: summary.latest_started_at, + planName: summary.latest_plan_name, + boardName: summary.latest_board_name, + durationSeconds: summary.latest_duration_seconds ?? 0, + } + : null, + daily: dailyRows.map((row) => this.#toDaily(row)), + }; + } + + #toDaily(row: z.infer) { + return { + date: row.date, + sessionCount: row.session_count, + durationSeconds: row.duration_seconds, + workDurationSeconds: row.work_duration_seconds, + restDurationSeconds: row.rest_duration_seconds, + }; + } +} diff --git a/packages/server/src/routers/activity.integration.test.ts b/packages/server/src/routers/activity.integration.test.ts index e7ce150241..af844105df 100644 --- a/packages/server/src/routers/activity.integration.test.ts +++ b/packages/server/src/routers/activity.integration.test.ts @@ -4,7 +4,10 @@ import { TEST_USER_ID } from "../../../../src/db/schema/core.ts"; import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts"; import { createSession } from "../auth/session.ts"; import { createApp } from "../index.ts"; -import { makeMockSensorStore } from "./test-helpers.ts"; +import { activityRouter } from "./activity.ts"; +import { createTestCallerFactory, makeMockSensorStore } from "./test-helpers.ts"; + +const createActivityCaller = createTestCallerFactory(activityRouter); describe("Activity router", () => { let server: ReturnType; @@ -339,3 +342,59 @@ describe("Activity router", () => { }); }); }); + +describe("Hangboarding activity router integration", () => { + let testContext: TestContext; + let activityId: string; + + beforeAll(async () => { + testContext = await setupTestDatabase(); + await testContext.db.execute( + sql`INSERT INTO fitness.provider (id, name, user_id) + VALUES ('hangboarding-activity-router-test', 'Hang Ten', ${TEST_USER_ID}) + ON CONFLICT DO NOTHING`, + ); + const rows = await testContext.db.execute<{ id: string }>( + sql`INSERT INTO fitness.activity ( + provider_id, user_id, external_id, canonical_type, provider_type, + started_at, ended_at, name, raw + ) VALUES ( + 'hangboarding-activity-router-test', ${TEST_USER_ID}, 'hangboard-activity-router-session', + 'hangboard', 'Hang Ten', '2026-08-08T14:00:00Z'::timestamptz, + '2026-08-08T14:10:00Z'::timestamptz, 'Repeaters', + '{"hangTen":{"sessionId":"router-session","planName":"Repeaters","boardName":"Tension Board"}}'::jsonb + ) RETURNING id::text AS id`, + ); + activityId = rows[0]?.id ?? ""; + if (!activityId) throw new Error("Failed to seed Hangboarding router activity"); + await testContext.db.execute( + sql`INSERT INTO fitness.activity_interval ( + activity_id, interval_index, label, interval_type, started_at, ended_at + ) VALUES ( + ${activityId}::uuid, 0, 'Step 1: Work', 'work', + '2026-08-08T14:00:00Z'::timestamptz, '2026-08-08T14:00:07Z'::timestamptz + )`, + ); + }, 60_000); + + afterAll(async () => { + await testContext?.cleanup(); + }); + + it("returns the detail contract and actionable not-found error", async () => { + const caller = createActivityCaller({ + db: testContext.db, + userId: TEST_USER_ID, + timezone: "UTC", + }); + await expect(caller.hangboardDetails({ id: activityId })).resolves.toMatchObject({ + planName: "Repeaters", + sessionId: "router-session", + boardName: "Tension Board", + intervals: [expect.objectContaining({ intervalType: "work", durationSeconds: 7 })], + }); + await expect( + caller.hangboardDetails({ id: "00000000-0000-0000-0000-000000000099" }), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Hangboarding details not found" }); + }); +}); diff --git a/packages/server/src/routers/activity.test.ts b/packages/server/src/routers/activity.test.ts index bb22e639da..7b13040641 100644 --- a/packages/server/src/routers/activity.test.ts +++ b/packages/server/src/routers/activity.test.ts @@ -187,6 +187,61 @@ function makeActivityRow(overrides: Partial): ActivityRow { } describe("activityRouter", () => { + describe("hangboardDetails", () => { + it("returns the Hangboarding detail contract", async () => { + const caller = makeCaller([ + { + activity_id: "activity-1", + canonical_type: "hangboard", + plan_name: "7/3 Repeaters", + session_id: "session-1", + board_id: "board-1", + board_name: "Tension Board", + segments_error: null, + interval_id: "interval-1", + interval_index: 0, + label: "Step 1: 19 mm edge", + interval_type: "work", + interval_started_at: "2026-08-07T14:00:00.000Z", + interval_ended_at: "2026-08-07T14:00:07.000Z", + duration_seconds: 7, + }, + ]); + + await expect( + caller.hangboardDetails({ id: "734b5d3e-df2b-4ee0-888e-55ea539d913a" }), + ).resolves.toEqual({ + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + ], + }); + }); + + it("returns an actionable not-found error for a non-Hangboarding activity", async () => { + const caller = makeCaller([]); + + await expect( + caller.hangboardDetails({ id: "734b5d3e-df2b-4ee0-888e-55ea539d913a" }), + ).rejects.toMatchObject>({ + code: "NOT_FOUND", + message: "Hangboarding details not found", + }); + }); + }); + describe("list", () => { it("returns paginated items with totalCount", async () => { const rows = [ diff --git a/packages/server/src/routers/activity.ts b/packages/server/src/routers/activity.ts index b9221b4ecf..8e4eeedc67 100644 --- a/packages/server/src/routers/activity.ts +++ b/packages/server/src/routers/activity.ts @@ -21,6 +21,7 @@ import { ActivityRepository, StreamPoint as StreamPointModel, } from "../repositories/activity-repository.ts"; +import { HangboardingRepository } from "../repositories/hangboarding-repository.ts"; import { PowerRepository } from "../repositories/power-repository.ts"; import { StrengthRepository } from "../repositories/strength-repository.ts"; import { CacheTTL, cachedProtectedQuery, protectedProcedure, router } from "../trpc.ts"; @@ -153,6 +154,25 @@ export const activityRouter = router({ return new Activity(row, getProvider).toDetail(); }), + hangboardDetails: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM }) + .input(z.object({ id: z.guid() })) + .query(async ({ ctx, input }) => { + const repository = new HangboardingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + ); + const detail = await repository.getDetail(input.id); + if (!detail) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hangboarding details not found", + }); + } + return detail; + }), + setPerceivedExertion: protectedProcedure .input(z.object({ id: z.guid(), value: z.number().min(0).max(10).nullable() })) .mutation(async ({ ctx, input }) => { diff --git a/packages/server/src/routers/climbing.integration.test.ts b/packages/server/src/routers/climbing.integration.test.ts index a2a9949454..70c039a6a4 100644 --- a/packages/server/src/routers/climbing.integration.test.ts +++ b/packages/server/src/routers/climbing.integration.test.ts @@ -12,6 +12,108 @@ const activityIdRowSchema = z.object({ id: z.string(), external_id: z.string(), }); +const hangboardingActivityIdRowSchema = activityIdRowSchema.extend({ + started_at: z.string(), +}); + +describe("Hangboarding climbing router integration", () => { + let testContext: TestContext; + let firstDate: string; + let secondDate: string; + + beforeAll(async () => { + testContext = await setupTestDatabase(); + await testContext.db.execute( + sql`INSERT INTO fitness.provider (id, name, user_id) + VALUES ('hangboarding-climbing-router-test', 'Hang Ten', ${TEST_USER_ID}) + ON CONFLICT DO NOTHING`, + ); + const activities = await executeWithSchema( + testContext.db, + hangboardingActivityIdRowSchema, + sql`INSERT INTO fitness.activity ( + provider_id, user_id, external_id, canonical_type, provider_type, + started_at, ended_at, name, raw + ) VALUES + ( + 'hangboarding-climbing-router-test', ${TEST_USER_ID}, 'hangboard-climbing-router-session-1', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '2 days', + CURRENT_TIMESTAMP - INTERVAL '2 days' + INTERVAL '10 minutes', 'Repeaters', + '{"avgHeartRate":120,"maxHeartRate":145,"hangTen":{"planName":"Repeaters","boardName":"Tension Board"}}'::jsonb + ), + ( + 'hangboarding-climbing-router-test', ${TEST_USER_ID}, 'hangboard-climbing-router-session-2', + 'hangboard', 'Hang Ten', CURRENT_TIMESTAMP - INTERVAL '1 day', + CURRENT_TIMESTAMP - INTERVAL '1 day' + INTERVAL '15 minutes', 'Max Hangs', + '{"avgHeartRate":130,"maxHeartRate":150,"hangTen":{"planName":"Max Hangs","boardName":"Tension Board"}}'::jsonb + ) + RETURNING id::text AS id, external_id, started_at::text AS started_at`, + ); + const firstActivity = activities.find( + (activity) => activity.external_id === "hangboard-climbing-router-session-1", + ); + const secondActivity = activities.find( + (activity) => activity.external_id === "hangboard-climbing-router-session-2", + ); + if (!firstActivity || !secondActivity) { + throw new Error("Failed to seed Hangboarding climbing router activities"); + } + firstDate = new Date(firstActivity.started_at).toISOString().slice(0, 10); + secondDate = new Date(secondActivity.started_at).toISOString().slice(0, 10); + await testContext.db.execute( + sql`INSERT INTO fitness.activity_interval ( + activity_id, interval_index, interval_type, started_at, ended_at + ) + SELECT activity.id, intervals.interval_index, intervals.interval_type, + activity.started_at + intervals.started_offset, + activity.started_at + intervals.ended_offset + FROM fitness.activity AS activity + CROSS JOIN (VALUES + (0, 'work', INTERVAL '0 seconds', INTERVAL '7 seconds'), + (1, 'rest', INTERVAL '7 seconds', INTERVAL '60 seconds') + ) AS intervals(interval_index, interval_type, started_offset, ended_offset) + WHERE activity.provider_id = 'hangboarding-climbing-router-test' + AND activity.external_id = 'hangboard-climbing-router-session-1' + UNION ALL + SELECT activity.id, intervals.interval_index, intervals.interval_type, + activity.started_at + intervals.started_offset, + activity.started_at + intervals.ended_offset + FROM fitness.activity AS activity + CROSS JOIN (VALUES + (0, 'work', INTERVAL '0 seconds', INTERVAL '10 seconds'), + (1, 'rest', INTERVAL '10 seconds', INTERVAL '60 seconds') + ) AS intervals(interval_index, interval_type, started_offset, ended_offset) + WHERE activity.provider_id = 'hangboarding-climbing-router-test' + AND activity.external_id = 'hangboard-climbing-router-session-2'`, + ); + }, 60_000); + + afterAll(async () => { + await testContext?.cleanup(); + }); + + it("returns exact server-computed Hangboarding summary totals", async () => { + const caller = createCaller({ + db: testContext.db, + userId: TEST_USER_ID, + timezone: "UTC", + }); + await expect(caller.hangboardingSummary({ days: 30 })).resolves.toMatchObject({ + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + daily: expect.arrayContaining([ + expect.objectContaining({ date: firstDate, durationSeconds: 600 }), + expect.objectContaining({ date: secondDate, durationSeconds: 900 }), + ]), + }); + }); +}); const countRowSchema = z.object({ count: z.string(), }); diff --git a/packages/server/src/routers/climbing.test.ts b/packages/server/src/routers/climbing.test.ts index a1a3ac3e72..efbd7dcb62 100644 --- a/packages/server/src/routers/climbing.test.ts +++ b/packages/server/src/routers/climbing.test.ts @@ -63,6 +63,18 @@ function makeCaller(rows: Record[] = []) { return { caller, execute }; } +function makeCallerWithResponses(responses: Record[][]) { + const execute = vi.fn(); + for (const response of responses) execute.mockResolvedValueOnce(response); + execute.mockResolvedValue([]); + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "America/Los_Angeles", + }); + return { caller, execute }; +} + function makeMutationCaller(error: unknown = new Error("database unavailable")) { const execute = vi.fn(); const transaction = vi.fn().mockRejectedValue(error); @@ -260,6 +272,64 @@ describe("climbingRouter", () => { ]); }); + it("returns the Hangboarding summary contract", async () => { + const { caller, execute } = makeCallerWithResponses([ + [ + { + session_count: 2, + total_duration_seconds: 1500, + average_duration_seconds: 750, + total_work_duration_seconds: 17, + total_rest_duration_seconds: 103, + work_interval_count: 2, + average_heart_rate: 125, + peak_heart_rate: 150, + latest_activity_id: "activity-2", + latest_started_at: "2026-08-08T14:00:00.000Z", + latest_plan_name: "Repeaters", + latest_board_name: "Tension Board", + latest_duration_seconds: 900, + }, + ], + [ + { + date: "2026-08-07", + session_count: 1, + duration_seconds: 600, + work_duration_seconds: 7, + rest_duration_seconds: 53, + }, + { + date: "2026-08-08", + session_count: 1, + duration_seconds: 900, + work_duration_seconds: 10, + rest_duration_seconds: 50, + }, + ], + ]); + + await expect(caller.hangboardingSummary({ days: 30 })).resolves.toMatchObject({ + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: expect.objectContaining({ + activityId: "activity-2", + durationSeconds: 900, + }), + daily: expect.arrayContaining([ + expect.objectContaining({ date: "2026-08-07", durationSeconds: 600 }), + expect.objectContaining({ date: "2026-08-08", durationSeconds: 900 }), + ]), + }); + expect(execute).toHaveBeenCalledTimes(2); + }); + it("returns empty arrays when there is no climbing data", async () => { const { caller, execute } = makeCaller([]); diff --git a/packages/server/src/routers/climbing.ts b/packages/server/src/routers/climbing.ts index 721279d457..9f7b56cbe8 100644 --- a/packages/server/src/routers/climbing.ts +++ b/packages/server/src/routers/climbing.ts @@ -18,6 +18,7 @@ import { fingerLoadingGripPositionSchema, fingerLoadingLateralitySchema, } from "../repositories/climbing-training-log-repository.ts"; +import { HangboardingRepository } from "../repositories/hangboarding-repository.ts"; import { CacheTTL, cachedProtectedQuery, protectedProcedure, router } from "../trpc.ts"; const daysInputSchema = z.object({ days: z.number().int().min(1).max(365).default(90) }); @@ -217,4 +218,16 @@ export const climbingRouter = router({ const rows = await runClimbingQuery(() => repository.getSessionSummaries(input.days)); return rows.map((row) => row.toDetail()); }), + + hangboardingSummary: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) + .input(daysInputSchema) + .query(async ({ ctx, input }) => { + const repository = new HangboardingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + ); + return runClimbingQuery(() => repository.getSummary(input.days)); + }), }); diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 3ab99205c4..c3eeccb640 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -1248,6 +1248,18 @@ describe("mobileDashboard.training", () => { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }); const timingCall = vi .mocked(logger.info) @@ -1293,6 +1305,18 @@ describe("mobileDashboard.training", () => { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }, }); @@ -1369,6 +1393,18 @@ describe("mobileDashboard.training", () => { gradeProgression: [], volumeByGrade: [], sessionSummary: [], + hangboarding: { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, }, }); diff --git a/packages/server/src/services/mobile-training-tab.test.ts b/packages/server/src/services/mobile-training-tab.test.ts index 87c094143a..d0d9153643 100644 --- a/packages/server/src/services/mobile-training-tab.test.ts +++ b/packages/server/src/services/mobile-training-tab.test.ts @@ -6,6 +6,7 @@ import { ClimbingVolumeByGrade, } from "../repositories/climbing-repository.ts"; import { VerticalAscentModel } from "../repositories/cycling-advanced-models.ts"; +import type { HangboardingSummary } from "../repositories/hangboarding-repository.ts"; import { ProgressiveOverload } from "../repositories/progressive-overload.ts"; import { loadMobileTrainingTab } from "./mobile-training-tab.ts"; @@ -58,6 +59,18 @@ describe("loadMobileTrainingTab", () => { weeklyVolume: unknown[] = [], verticalAscent: VerticalAscentModel[] = [], progressiveOverload: ProgressiveOverload[] = [], + hangboardingSummary: HangboardingSummary = { + sessionCount: 0, + totalDurationSeconds: 0, + averageDurationSeconds: null, + totalWorkDurationSeconds: null, + totalRestDurationSeconds: null, + workIntervalCount: null, + averageHeartRate: null, + peakHeartRate: null, + latestSession: null, + daily: [], + }, ) { const trainingSpy = vi .spyOn( @@ -86,7 +99,14 @@ describe("loadMobileTrainingTab", () => { "getProgressiveOverload", ) .mockResolvedValue(progressiveOverload); - return { trainingSpy, cyclingSpy, strengthSpy }; + const hangboardingSpy = vi + .spyOn( + (await import("../repositories/hangboarding-repository.ts")).HangboardingRepository + .prototype, + "getSummary", + ) + .mockResolvedValue(hangboardingSummary); + return { trainingSpy, cyclingSpy, strengthSpy, hangboardingSpy }; } async function mockClimbingRepos() { @@ -184,6 +204,24 @@ describe("loadMobileTrainingTab", () => { { week: "2026-03-23", totalVolumeKg: 1_200 }, ]), ], + { + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "7/3 Repeaters", + boardName: "Tension Board", + durationSeconds: 900, + }, + daily: [], + }, ); await mockClimbingRepos(); @@ -245,6 +283,18 @@ describe("loadMobileTrainingTab", () => { hardestRouteGradeSortValue: 5101, }, ], + hangboarding: { + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: expect.objectContaining({ planName: "7/3 Repeaters" }), + daily: expect.any(Array), + }, }); }); diff --git a/packages/server/src/services/mobile-training-tab.ts b/packages/server/src/services/mobile-training-tab.ts index ad4d26bfeb..b6c88fca5b 100644 --- a/packages/server/src/services/mobile-training-tab.ts +++ b/packages/server/src/services/mobile-training-tab.ts @@ -14,6 +14,7 @@ import { dateWindowStartString } from "../lib/date-window.ts"; import type { ActivitySensorStore } from "../repositories/activity-repository.ts"; import { ClimbingRepository } from "../repositories/climbing-repository.ts"; import { CyclingAnalyticsRepository } from "../repositories/cycling-analytics-repository.ts"; +import { HangboardingRepository } from "../repositories/hangboarding-repository.ts"; import { StrengthRepository } from "../repositories/strength-repository.ts"; import { TrainingRepository } from "../repositories/training-repository.ts"; import { @@ -79,6 +80,12 @@ export async function loadMobileTrainingTab( ctx.accessWindow, ); const climbingRepo = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); + const hangboardingRepo = new HangboardingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + ); const strengthRepo = new StrengthRepository(ctx.db, ctx.userId, ctx.timezone); const windowStart = dateWindowStartString(endDate, days); @@ -152,6 +159,7 @@ export async function loadMobileTrainingTab( gradeProgressionModels, volumeByGradeModels, sessionSummaryModels, + hangboardingSummary, progressiveOverloadModels, ] = await Promise.all([ trainingRepo.getActivityStatsAndWeeklyVolume(days), @@ -164,6 +172,7 @@ export async function loadMobileTrainingTab( climbingRepo.getGradeProgression(days), climbingRepo.getVolumeByGrade(days), climbingRepo.getSessionSummaries(days), + hangboardingRepo.getSummary(days), strengthRepo.getProgressiveOverload(days), ]); @@ -201,6 +210,7 @@ export async function loadMobileTrainingTab( gradeProgression: gradeProgressionModels.map((model) => model.toDetail()), volumeByGrade: volumeByGradeModels.map((model) => model.toDetail()), sessionSummary: sessionSummaryModels.map((model) => model.toDetail()), + hangboarding: hangboardingSummary, }, }; } diff --git a/packages/trainerroad-client/package.json b/packages/trainerroad-client/package.json index 63d771448c..f0fc3de1cb 100644 --- a/packages/trainerroad-client/package.json +++ b/packages/trainerroad-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/trainerroad", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial TrainerRoad API client using reverse-engineered cookie-based authentication", "type": "module", "license": "MIT", diff --git a/packages/training/package.json b/packages/training/package.json index a150233872..12eef0b791 100644 --- a/packages/training/package.json +++ b/packages/training/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/training", - "version": "0.1.1", + "version": "0.1.4", "description": "Training-science calculations for power, load, performance, and workout planning", "type": "module", "license": "MIT", diff --git a/packages/training/src/activity-types.test.ts b/packages/training/src/activity-types.test.ts index a4b2cd7479..7da979196b 100644 --- a/packages/training/src/activity-types.test.ts +++ b/packages/training/src/activity-types.test.ts @@ -147,6 +147,12 @@ const EXPECTED_LEGACY_CLASSIFICATIONS = [ ExpectedLegacyClassification["modality"], ])[]; +describe("CANONICAL_ACTIVITY_TYPES", () => { + it("includes hangboard as a canonical activity type", () => { + expect(CANONICAL_ACTIVITY_TYPES).toContain("hangboard"); + }); +}); + describe("legacy activity classification", () => { it("covers every legacy enum value exactly once", () => { const expectedLegacyTypes = EXPECTED_LEGACY_CLASSIFICATIONS.map(([legacyType]) => legacyType); @@ -174,6 +180,14 @@ describe("legacy activity classification", () => { }); describe("resolveProviderActivityType", () => { + it("preserves the canonical hangboard activity type", () => { + expect(CANONICAL_ACTIVITY_TYPES).toContain("hangboard"); + expect(resolveProviderActivityType("Hang Ten", "hangboard")).toMatchObject({ + canonicalType: "hangboard", + providerType: "Hang Ten", + }); + }); + it("retains string provider types verbatim", () => { expect(resolveProviderActivityType("Ride", "road_cycling")).toEqual({ canonicalType: "cycling", diff --git a/packages/training/src/activity-types.ts b/packages/training/src/activity-types.ts index 64d45e9f2f..d636ce29bd 100644 --- a/packages/training/src/activity-types.ts +++ b/packages/training/src/activity-types.ts @@ -66,6 +66,7 @@ export const CANONICAL_ACTIVITY_TYPES = [ "golf", "disc_golf", "climbing", + "hangboard", "dance", "triathlon", "multisport", diff --git a/packages/training/src/training.test.ts b/packages/training/src/training.test.ts index 4e73c4603e..74b909a7b2 100644 --- a/packages/training/src/training.test.ts +++ b/packages/training/src/training.test.ts @@ -298,8 +298,14 @@ describe("RIDE_WITH_GPS_ACTIVITY_TYPE_MAP", () => { }); describe("formatActivityTypeLabel", () => { + it("labels hangboard activity types as Hangboarding", () => { + expect(CANONICAL_ACTIVITY_TYPES).toContain("hangboard"); + expect(formatActivityTypeLabel("hangboard")).toBe("Hangboarding"); + }); + it("maps known activity types to human-readable names", () => { expect(formatActivityTypeLabel("functional_strength")).toBe("Functional Strength"); + expect(formatActivityTypeLabel("hangboard")).toBe("Hangboarding"); expect(formatActivityTypeLabel("strength_training")).toBe("Strength Training"); }); diff --git a/packages/training/src/training.ts b/packages/training/src/training.ts index d9d2cdab2d..446ee1ab31 100644 --- a/packages/training/src/training.ts +++ b/packages/training/src/training.ts @@ -348,6 +348,7 @@ const ACTIVITY_TYPE_LABELS: Record = { strength: "Strength", strength_training: "Strength Training", functional_strength: "Functional Strength", + hangboard: "Hangboarding", stair_climbing: "Stair Climbing", cross_training: "Cross Training", hiit: "HIIT", diff --git a/packages/trainingpeaks-connect/package.json b/packages/trainingpeaks-connect/package.json index 9e1f1e336e..c495fffa0a 100644 --- a/packages/trainingpeaks-connect/package.json +++ b/packages/trainingpeaks-connect/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/trainingpeaks", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial TrainingPeaks internal API client using cookie-based authentication", "type": "module", "license": "MIT", diff --git a/packages/velohero-client/package.json b/packages/velohero-client/package.json index b7de9526ec..94c4f9cd32 100644 --- a/packages/velohero-client/package.json +++ b/packages/velohero-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/velohero", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial VeloHero API client using reverse-engineered session authentication", "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 0d6848ec8d..da6d04dba0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,7 +24,7 @@ "@dofek/training": "workspace:*", "@dofek/zones": "workspace:*", "@radix-ui/react-dialog": "1.1.23", - "@sentry/react": "10.45.0", + "@sentry/react": "10.69.0", "@tanstack/react-query": "5.101.2", "@tanstack/react-router": "1.170.17", "@tanstack/react-table": "8.21.3", @@ -36,7 +36,7 @@ "html-escaper": "3.0.3", "leaflet": "1.9.4", "posthog-js": "1.374.2", - "react": "19.2.3", + "react": "19.2.8", "react-body-highlighter": "2.0.5", "react-dom": "19.2.8", "react-spinners": "0.17.0", @@ -45,13 +45,13 @@ "devDependencies": { "@sentry/vite-plugin": "5.3.0", "@storybook/addon-a11y": "10.5.4", - "@storybook/react-vite": "10.5.4", + "@storybook/react-vite": "10.5.5", "@tailwindcss/vite": "4.3.2", "@tanstack/router-cli": "1.167.17", "@tanstack/router-plugin": "1.168.19", "@types/html-escaper": "3.0.4", "@types/leaflet": "1.9.21", - "@types/react": "19.2.14", + "@types/react": "19.2.18", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "storybook": "10.5.4", diff --git a/packages/web/src/components/HangboardingDetail.stories.tsx b/packages/web/src/components/HangboardingDetail.stories.tsx new file mode 100644 index 0000000000..76e920a040 --- /dev/null +++ b/packages/web/src/components/HangboardingDetail.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { HangboardingDetail as HangboardingDetailData } from "../../../server/src/repositories/hangboarding-repository.ts"; +import { HangboardingDetail } from "./HangboardingDetail.tsx"; + +const data: HangboardingDetailData = { + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + { + id: "interval-2", + intervalIndex: 1, + label: "Rest", + intervalType: "rest", + startedAt: "2026-08-07T14:00:07.000Z", + endedAt: "2026-08-07T14:00:53.000Z", + durationSeconds: 46, + }, + ], +}; + +const meta = { + title: "Training/HangboardingDetail", + component: HangboardingDetail, + tags: ["autodocs"], + args: { data, loading: false, error: null }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SegmentWarning: Story = { + args: { + data: { ...data, segmentsError: "Some intervals had incomplete timestamps." }, + }, +}; + +export const Loading: Story = { args: { data: undefined, loading: true, error: null } }; diff --git a/packages/web/src/components/HangboardingDetail.test.tsx b/packages/web/src/components/HangboardingDetail.test.tsx new file mode 100644 index 0000000000..d7a9b30919 --- /dev/null +++ b/packages/web/src/components/HangboardingDetail.test.tsx @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { HangboardingDetail as HangboardingDetailData } from "../../../server/src/repositories/hangboarding-repository.ts"; + +import { HangboardingDetail } from "./HangboardingDetail.tsx"; + +const detail: HangboardingDetailData = { + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [ + { + id: "interval-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: "2026-08-07T14:00:00.000Z", + endedAt: "2026-08-07T14:00:07.000Z", + durationSeconds: 7, + }, + { + id: "interval-2", + intervalIndex: 1, + label: "Rest", + intervalType: "rest", + startedAt: "2026-08-07T14:00:07.000Z", + endedAt: "2026-08-07T14:00:53.000Z", + durationSeconds: 46, + }, + ], +}; + +afterEach(cleanup); + +describe("HangboardingDetail", () => { + it("renders plan and board metadata plus intervals in index order", () => { + render(); + + expect(screen.getByText("Plan")).toBeTruthy(); + expect(screen.getByText("7/3 Repeaters")).toBeTruthy(); + expect(screen.getByText("Session")).toBeTruthy(); + expect(screen.getByText("session-1")).toBeTruthy(); + expect(screen.getByText("Board")).toBeTruthy(); + expect(screen.getByText("Tension Board")).toBeTruthy(); + + const labels = screen.getAllByTestId("hangboarding-interval-label"); + expect(labels.map((label) => label.textContent)).toEqual(["Step 1: 19 mm edge", "Rest"]); + expect(screen.getByText("7s")).toBeTruthy(); + expect(screen.getByText("46s")).toBeTruthy(); + }); + + it("renders an actionable segments note without hiding valid data", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("Segment 3 had no end timestamp"); + expect(screen.getByText("7/3 Repeaters")).toBeTruthy(); + expect(screen.getByText("Step 1: 19 mm edge")).toBeTruthy(); + }); + + it("uses the query-state loading and error conventions", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("query-state-loading")).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByText("Hangboarding details unavailable")).toBeTruthy(); + expect(screen.getByTestId("query-state-error")).toBeTruthy(); + }); +}); diff --git a/packages/web/src/components/HangboardingDetail.tsx b/packages/web/src/components/HangboardingDetail.tsx new file mode 100644 index 0000000000..cb62ae74f6 --- /dev/null +++ b/packages/web/src/components/HangboardingDetail.tsx @@ -0,0 +1,117 @@ +import { formatDateTime, formatDurationSeconds } from "@dofek/format/format"; +import type { HangboardingDetail as HangboardingDetailData } from "../../../server/src/repositories/hangboarding-repository.ts"; +import { QueryStatePanel } from "./QueryStatePanel.tsx"; + +interface HangboardingDetailProps { + data: HangboardingDetailData | undefined; + loading: boolean; + error: unknown; +} + +function nullableValue(value: string | null): string { + return value ?? "—"; +} + +export function HangboardingDetail({ data, loading, error }: HangboardingDetailProps) { + if (data == null && loading) { + return ; + } + + if (data == null && error) { + return ; + } + + if (data == null) { + return ( + + ); + } + + return ( +
    + {error ? : null} + {data.segmentsError ? ( +
    + Some Hangboarding intervals could not be imported: {data.segmentsError} Re-import the + activity to try again. +
    + ) : null} + +
    + + + + +
    + + {data.intervals.length === 0 ? ( + + ) : ( +
    + + + + + + + + + + + + + {data.intervals.map((interval) => ( + + + + + + + + ))} + +
    Hangboarding intervals
    + Interval + + Type + + Started + + Ended + + Duration +
    + {nullableValue(interval.label)} + {nullableValue(interval.intervalType)} + {formatDateTime(interval.startedAt)} + + {interval.endedAt == null ? "—" : formatDateTime(interval.endedAt)} + + {interval.durationSeconds == null + ? "—" + : formatDurationSeconds(interval.durationSeconds)} +
    +
    + )} +
    + ); +} + +function Metadata({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} diff --git a/packages/web/src/components/HangboardingSummary.stories.tsx b/packages/web/src/components/HangboardingSummary.stories.tsx new file mode 100644 index 0000000000..08443be6f2 --- /dev/null +++ b/packages/web/src/components/HangboardingSummary.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../../server/src/repositories/hangboarding-repository.ts"; +import { HangboardingSummary } from "./HangboardingSummary.tsx"; + +const data: HangboardingSummaryData = { + sessionCount: 4, + totalDurationSeconds: 3120, + averageDurationSeconds: 780, + totalWorkDurationSeconds: 240, + totalRestDurationSeconds: 1440, + workIntervalCount: 24, + averageHeartRate: 126, + peakHeartRate: 154, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "7/3 Repeaters", + boardName: "Tension Board", + durationSeconds: 840, + }, + daily: [ + { + date: "2026-08-05", + sessionCount: 1, + durationSeconds: 720, + workDurationSeconds: 60, + restDurationSeconds: 360, + }, + { + date: "2026-08-07", + sessionCount: 2, + durationSeconds: 1560, + workDurationSeconds: 120, + restDurationSeconds: 720, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 840, + workDurationSeconds: 60, + restDurationSeconds: 360, + }, + ], +}; + +const meta = { + title: "Training/HangboardingSummary", + component: HangboardingSummary, + tags: ["autodocs"], + args: { data, loading: false }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { args: { data: undefined, loading: true } }; + +export const Empty: Story = { + args: { data: { ...data, sessionCount: 0, daily: [], latestSession: null }, loading: false }, +}; diff --git a/packages/web/src/components/HangboardingSummary.test.tsx b/packages/web/src/components/HangboardingSummary.test.tsx new file mode 100644 index 0000000000..2443362c32 --- /dev/null +++ b/packages/web/src/components/HangboardingSummary.test.tsx @@ -0,0 +1,138 @@ +/** @vitest-environment jsdom */ + +import { formatDateTime } from "@dofek/format/format"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../../server/src/repositories/hangboarding-repository.ts"; + +const chart = vi.hoisted(() => vi.fn()); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + params, + to, + }: { + children: React.ReactNode; + params?: { id?: string }; + to: string; + }) => {children}, +})); + +vi.mock("./DofekChart.tsx", () => ({ + DofekChart: (props: { option: Record }) => { + chart(props); + return
    ; + }, +})); + +import { HangboardingSummary } from "./HangboardingSummary.tsx"; + +const summary: HangboardingSummaryData = { + sessionCount: 2, + totalDurationSeconds: 1500, + averageDurationSeconds: 750, + totalWorkDurationSeconds: 17, + totalRestDurationSeconds: 103, + workIntervalCount: 2, + averageHeartRate: 125, + peakHeartRate: 150, + latestSession: { + activityId: "activity-2", + startedAt: "2026-08-08T14:00:00.000Z", + planName: "Repeaters", + boardName: "Tension Board", + durationSeconds: 900, + }, + daily: [ + { + date: "2026-08-07", + sessionCount: 1, + durationSeconds: 600, + workDurationSeconds: 7, + restDurationSeconds: 53, + }, + { + date: "2026-08-08", + sessionCount: 1, + durationSeconds: 900, + workDurationSeconds: 10, + restDurationSeconds: 50, + }, + ], +}; + +afterEach(() => { + cleanup(); + chart.mockReset(); +}); + +describe("HangboardingSummary", () => { + it("renders server-provided summary metrics and latest-session metadata", () => { + render(); + + for (const label of [ + "Sessions", + "Total Time", + "Avg Session", + "Work Time", + "Rest Time", + "Work Intervals", + "Avg Heart Rate", + "Peak Heart Rate", + ]) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.getAllByText("2")).toHaveLength(2); + expect(screen.getByText("25m")).toBeTruthy(); + expect(screen.getByText("13m")).toBeTruthy(); + expect(screen.getByText("17s")).toBeTruthy(); + expect(screen.getByText("2m")).toBeTruthy(); + expect(screen.getByText("125 bpm")).toBeTruthy(); + expect(screen.getByText("150 bpm")).toBeTruthy(); + expect(screen.getByText("Repeaters")).toBeTruthy(); + const latestSessionLink = screen.getByRole("link", { name: /Repeaters.*Tension Board/ }); + expect(latestSessionLink).toHaveAttribute("href", "/activity/activity-2"); + expect(screen.getByText("Started")).toBeTruthy(); + if (!summary.latestSession) throw new Error("Expected a latest Hangboarding session"); + expect(screen.getByText(formatDateTime(summary.latestSession.startedAt))).toBeTruthy(); + expect(screen.getByText("15m")).toBeTruthy(); + }); + + it("renders nullable work, rest, and heart-rate values as em dashes", () => { + render( + , + ); + + expect(screen.getAllByText("—")).toHaveLength(5); + expect(screen.queryByText("0s")).toBeNull(); + expect(screen.queryByText("0 bpm")).toBeNull(); + }); + + it("uses the query-state loading convention before data is available", () => { + render(); + + expect(screen.getByTestId("query-state-loading")).toBeTruthy(); + }); + + it("renders an explicit empty state when there are no sessions", () => { + render( + , + ); + + expect(screen.getByText("No Hangboarding sessions yet.")).toBeTruthy(); + }); +}); diff --git a/packages/web/src/components/HangboardingSummary.tsx b/packages/web/src/components/HangboardingSummary.tsx new file mode 100644 index 0000000000..89cbba4452 --- /dev/null +++ b/packages/web/src/components/HangboardingSummary.tsx @@ -0,0 +1,115 @@ +import { formatDateTime, formatDurationSeconds, formatNumber } from "@dofek/format/format"; +import { Link } from "@tanstack/react-router"; +import type { HangboardingSummary as HangboardingSummaryData } from "../../../server/src/repositories/hangboarding-repository.ts"; +import { chartColors, dofekAxis, dofekGrid, dofekSeries, dofekTooltip } from "../lib/chartTheme.ts"; +import { DofekChart } from "./DofekChart.tsx"; +import { QueryStatePanel } from "./QueryStatePanel.tsx"; + +interface HangboardingSummaryProps { + data: HangboardingSummaryData | undefined; + loading: boolean; +} + +function formatNullableDuration(value: number | null): string { + return value == null ? "—" : formatDurationSeconds(value); +} + +function formatNullableHeartRate(value: number | null): string { + return value == null ? "—" : `${formatNumber(value, 0)} bpm`; +} + +export function HangboardingSummary({ data, loading }: HangboardingSummaryProps) { + if (data == null && loading) { + return ; + } + + if (data == null || data.sessionCount === 0) { + return ; + } + + const latestSession = data.latestSession; + const dailyDurationOption = { + grid: dofekGrid("single", { top: 12, right: 20, bottom: 36, left: 52 }), + tooltip: dofekTooltip(), + xAxis: dofekAxis.time(), + yAxis: dofekAxis.value({ + name: "Duration", + axisLabel: { formatter: (value: number) => formatDurationSeconds(value) }, + }), + series: [ + dofekSeries.bar( + "Session duration", + data.daily.map((row) => [row.date, row.durationSeconds]), + { color: chartColors.blue, barWidth: "55%" }, + ), + ], + }; + + return ( +
    +
    + + + + + + + + +
    + +
    +

    + Daily Duration +

    + +
    + + {latestSession ? ( +
    +
    Latest Session
    + + {latestSession.planName ?? "Hangboarding session"} + {latestSession.boardName ? ( + · {latestSession.boardName} + ) : null} + +
    +
    +
    Started
    +
    {formatDateTime(latestSession.startedAt)}
    +
    +
    +
    Duration
    +
    + {formatDurationSeconds(latestSession.durationSeconds)} +
    +
    +
    +
    + ) : null} +
    + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} diff --git a/packages/web/src/pages/ActivitiesPage.test.tsx b/packages/web/src/pages/ActivitiesPage.test.tsx index 73525cd601..b7e4c53640 100644 --- a/packages/web/src/pages/ActivitiesPage.test.tsx +++ b/packages/web/src/pages/ActivitiesPage.test.tsx @@ -444,6 +444,47 @@ describe("ActivitiesPage", () => { expect(screen.getByText("0 m")).toBeDefined(); }); + it("renders available partial overview measurements and comparisons", () => { + mockOverviewQuery = { + data: { + activityCount: 4, + totalMinutes: 280, + totalDistanceMeters: 12500, + totalDistanceState: { status: "available" }, + totalElevationGainM: 180, + totalElevationState: { status: "available" }, + activityTypes: ["running", "cycling"], + comparison: { + periodLabel: "previous 4 weeks", + activityCount: { magnitude: 1, trend: "higher" }, + totalMinutes: { magnitude: 60, trend: "higher" }, + totalDistanceMeters: { + magnitude: 2500, + trend: "higher", + state: { status: "available" }, + }, + totalElevationGainM: { + magnitude: 50, + trend: "higher", + state: { status: "available" }, + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }; + + render(); + + expect(screen.getByText("12.5 km")).toBeDefined(); + expect(screen.getByText("180 m")).toBeDefined(); + expect(screen.getByText("2.5 km more vs previous 4 weeks")).toBeDefined(); + expect(screen.getByText("50 m more vs previous 4 weeks")).toBeDefined(); + expect(screen.getByText("Distance").parentElement).not.toHaveTextContent(/unavailable/); + expect(screen.getByText("Elevation").parentElement).not.toHaveTextContent(/unavailable/); + }); + it("passes selected filters to the activity list query", () => { mockOverviewQuery = { data: { diff --git a/packages/web/src/pages/ActivityDetailPage.test.tsx b/packages/web/src/pages/ActivityDetailPage.test.tsx index 19d67cb473..32770ef8c5 100644 --- a/packages/web/src/pages/ActivityDetailPage.test.tsx +++ b/packages/web/src/pages/ActivityDetailPage.test.tsx @@ -138,6 +138,14 @@ const mockStrengthExercisesUseQuery = vi.fn( isLoading: false, }), ); +const mockHangboardDetailsUseQuery = vi.fn( + (_input?: unknown, _options?: { enabled?: boolean }): MockQueryResult => ({ + data: undefined, + error: null, + isError: false, + isLoading: false, + }), +); const mockClimbingEntriesUseQuery = vi.fn( ( _input?: unknown, @@ -232,6 +240,7 @@ vi.mock("../lib/trpc.ts", () => ({ }), }, strengthExercises: { useQuery: mockStrengthExercisesUseQuery }, + hangboardDetails: { useQuery: mockHangboardDetailsUseQuery }, recompute: { useMutation: (options?: { onSuccess?: () => Promise; @@ -307,6 +316,13 @@ afterEach(() => { isError: false, isLoading: false, }); + mockHangboardDetailsUseQuery.mockReset(); + mockHangboardDetailsUseQuery.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: false, + }); mockPowerZonesUseQuery.mockReset(); mockPowerZonesUseQuery.mockReturnValue({ data: undefined, @@ -324,6 +340,7 @@ afterEach(() => { function renderWithUnits(ui: ReactNode, unitSystem: UnitSystem = "metric") { capturedOptions.length = 0; mockStrengthExercisesUseQuery.mockClear(); + mockHangboardDetailsUseQuery.mockClear(); mockClimbingEntriesUseQuery.mockClear(); mockHrZonesUseQuery.mockClear(); mockPowerZonesUseQuery.mockClear(); @@ -1104,6 +1121,41 @@ describe("ActivityDetailPage", () => { }); }); + describe("Hangboarding detail query gating", () => { + it("disables Hangboarding details for non-Hangboarding activities", async () => { + const ActivityDetailPage = await importPage(); + renderWithUnits(); + + expect(getQueryEnabledFlag(mockHangboardDetailsUseQuery.mock.calls[0]?.[1])).toBe(false); + }); + + it("enables Hangboarding details and uses the Hangboarding label", async () => { + const originalData = { ...mockActivity }; + Object.assign(mockActivity, { activityType: "hangboard", name: "Repeaters" }); + mockHangboardDetailsUseQuery.mockReturnValue({ + data: { + planName: "7/3 Repeaters", + sessionId: "session-1", + boardId: "board-1", + boardName: "Tension Board", + segmentsError: null, + intervals: [], + }, + error: null, + isError: false, + isLoading: false, + }); + + const ActivityDetailPage = await importPage(); + renderWithUnits(); + + expect(getQueryEnabledFlag(mockHangboardDetailsUseQuery.mock.calls[0]?.[1])).toBe(true); + expect(screen.getAllByText("Hangboarding").length).toBeGreaterThanOrEqual(2); + + Object.assign(mockActivity, originalData); + }); + }); + describe("climbing entries", () => { it("shows the climbs attached to a merged rock-climbing activity", async () => { const originalData = { ...mockActivity }; diff --git a/packages/web/src/pages/ActivityDetailPage.tsx b/packages/web/src/pages/ActivityDetailPage.tsx index da317697ab..d112cef022 100644 --- a/packages/web/src/pages/ActivityDetailPage.tsx +++ b/packages/web/src/pages/ActivityDetailPage.tsx @@ -37,6 +37,7 @@ import { ActivityPerceivedExertion } from "../components/ActivityPerceivedExerti import { ActivitySourceDecisionCard } from "../components/ActivitySourceDecisionCard.tsx"; import { ChartDescriptionTooltip } from "../components/ChartDescriptionTooltip.tsx"; import { DofekChart } from "../components/DofekChart.tsx"; +import { HangboardingDetail } from "../components/HangboardingDetail.tsx"; import { HrZonesChart, PowerZonesChart } from "../components/HeartRateZonesChart.tsx"; import { ChartLoadingSkeleton } from "../components/LoadingSkeleton.tsx"; import { PageLayout } from "../components/PageLayout.tsx"; @@ -100,6 +101,10 @@ function isClimbingActivityType(activityType: string): boolean { return activityType === "climbing"; } +function isHangboardingActivityType(activityType: string): boolean { + return activityType === "hangboard"; +} + export function ActivityDetailPage() { const { id } = useParams({ from: "/activity/$id" }); @@ -132,6 +137,12 @@ export function ActivityDetailPage() { { id }, { enabled: isClimbingActivity }, ); + const isHangboardingActivity = + detail.data != null && isHangboardingActivityType(detail.data.activityType); + const hangboardDetails = trpc.activity.hangboardDetails.useQuery( + { id }, + { enabled: isHangboardingActivity }, + ); // Ref-based hover callback avoids re-rendering the entire page on every mouse move. // RouteMap registers its marker-update function here; charts call it directly. @@ -286,6 +297,19 @@ export function ActivityDetailPage() { )} + {isHangboardingActivity && ( +
    + +
    + )} +
    {hasAltitude && (
    vi.fn()); const volumeByGradeQuery = vi.hoisted(() => vi.fn()); const sessionSummaryQuery = vi.hoisted(() => vi.fn()); +const hangboardingSummaryQuery = vi.hoisted(() => vi.fn()); const fingerLoadingHistoryQuery = vi.hoisted(() => vi.fn()); const logFingerLoadingMutation = vi.hoisted(() => vi.fn()); const logClimbingSessionMutation = vi.hoisted(() => vi.fn()); @@ -50,6 +51,10 @@ vi.mock("../../components/RecentActivitiesSection.tsx", () => ({ }, })); +vi.mock("../../components/HangboardingSummary.tsx", () => ({ + HangboardingSummary: () =>
    Hangboarding Summary component
    , +})); + vi.mock("../../components/QueryStatePanel.tsx", () => ({ QueryStatePanel: ({ error }: { error?: Error | null }) => (
    {error ? `Error: ${error.message}` : "Query state"}
    @@ -69,6 +74,7 @@ vi.mock("../../lib/trpc.ts", () => ({ logClimbingSession: { useMutation: logClimbingSessionMutation }, volumeByGrade: { useQuery: volumeByGradeQuery }, sessionSummary: { useQuery: sessionSummaryQuery }, + hangboardingSummary: { useQuery: hangboardingSummaryQuery }, }, useUtils: () => ({ activity: { invalidate: vi.fn() }, @@ -92,6 +98,7 @@ describe("ClimbingTab", () => { gradeProgressionQuery.mockReset(); volumeByGradeQuery.mockReset(); sessionSummaryQuery.mockReset(); + hangboardingSummaryQuery.mockReset(); fingerLoadingHistoryQuery.mockReset(); logFingerLoadingMutation.mockReset(); logClimbingSessionMutation.mockReset(); @@ -100,6 +107,7 @@ describe("ClimbingTab", () => { gradeProgressionQuery.mockReturnValue({ data: [], isLoading: false, error: null }); volumeByGradeQuery.mockReturnValue({ data: [], isLoading: false, error: null }); sessionSummaryQuery.mockReturnValue({ data: [], isLoading: false, error: null }); + hangboardingSummaryQuery.mockReturnValue({ data: undefined, isLoading: false, error: null }); fingerLoadingHistoryQuery.mockReturnValue({ data: [], isLoading: false, error: null }); logFingerLoadingMutation.mockReturnValue({ error: null, isPending: false, mutate: vi.fn() }); logClimbingSessionMutation.mockReturnValue({ error: null, isPending: false, mutate: vi.fn() }); @@ -129,6 +137,7 @@ describe("ClimbingTab", () => { expect(gradeProgressionQuery).toHaveBeenCalledWith({ days: 90 }, expect.any(Object)); expect(volumeByGradeQuery).toHaveBeenCalledWith({ days: 90 }, expect.any(Object)); expect(sessionSummaryQuery).toHaveBeenCalledWith({ days: 90 }, expect.any(Object)); + expect(hangboardingSummaryQuery).toHaveBeenCalledWith({ days: 90 }, expect.any(Object)); const sectionProps = recentActivitiesSection.mock.calls[0]?.[0]; expect(sectionProps.activityTypes).toEqual(["climbing"]); expect(sectionProps.additionalColumns.map((column: { key: string }) => column.key)).toEqual([ @@ -150,6 +159,7 @@ describe("ClimbingTab", () => { expect(gradeProgressionQuery).toHaveBeenCalledWith({}, expect.any(Object)); expect(volumeByGradeQuery).toHaveBeenCalledWith({}, expect.any(Object)); expect(sessionSummaryQuery).toHaveBeenCalledWith({}, expect.any(Object)); + expect(hangboardingSummaryQuery).toHaveBeenCalledWith({}, expect.any(Object)); }); it("renders one recent climbing table", async () => { @@ -159,6 +169,7 @@ describe("ClimbingTab", () => { expect(screen.getByText("Grade Progression")).toBeTruthy(); expect(screen.getByText("Volume by Grade")).toBeTruthy(); expect(screen.getByText("Recent Climbing Activities")).toBeTruthy(); + expect(screen.getByText("Hangboarding")).toBeTruthy(); expect(screen.queryByText("Recent Climbing Sessions")).toBeNull(); }); diff --git a/packages/web/src/routes/training/climbing.tsx b/packages/web/src/routes/training/climbing.tsx index d9b592fc6d..af8270f249 100644 --- a/packages/web/src/routes/training/climbing.tsx +++ b/packages/web/src/routes/training/climbing.tsx @@ -13,6 +13,7 @@ import { FingerLoadingLog, type FingerLoadingSubmission, } from "../../components/FingerLoadingLog.tsx"; +import { HangboardingSummary } from "../../components/HangboardingSummary.tsx"; import { QueryStatePanel } from "../../components/QueryStatePanel.tsx"; import { RecentActivitiesSection } from "../../components/RecentActivitiesSection.tsx"; import { captureException } from "../../lib/telemetry.ts"; @@ -91,6 +92,10 @@ export function ClimbingTab() { rangeInput, TRAINING_SLOW_QUERY_OPTIONS, ); + const hangboardingSummary = trpc.climbing.hangboardingSummary.useQuery( + rangeInput, + TRAINING_SLOW_QUERY_OPTIONS, + ); const fingerLoadingHistory = trpc.climbing.fingerLoadingHistory.useQuery({ days: 90 }); const utils = trpc.useUtils(); const fingerLoadingMutation = trpc.climbing.logFingerLoading.useMutation({ @@ -110,6 +115,7 @@ export function ClimbingTab() { onSuccess: async () => { await Promise.all([ utils.climbing.invalidate(), + utils.climbing.hangboardingSummary.invalidate(), utils.activity.invalidate(), utils.mobileDashboard.training.invalidate(), ]); @@ -186,6 +192,23 @@ export function ClimbingTab() { />
    + +
    + {hangboardingSummary.error && !hangboardingSummary.data ? ( + + ) : ( + + )} + {hangboardingSummary.error && hangboardingSummary.data ? ( + + ) : null} +
    ); } diff --git a/packages/whoop-whoop/package.json b/packages/whoop-whoop/package.json index c745c9c997..9388ddcee3 100644 --- a/packages/whoop-whoop/package.json +++ b/packages/whoop-whoop/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/whoop", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial WHOOP API client using the internal Cognito-based authentication", "type": "module", "license": "MIT", diff --git a/packages/xert-client/package.json b/packages/xert-client/package.json index 9fa4fdf5aa..d4d3d08d83 100644 --- a/packages/xert-client/package.json +++ b/packages/xert-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/xert", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Xert API client with password authentication, token refresh, and activity parsing", "type": "module", "license": "MIT", diff --git a/packages/zepp-client/package.json b/packages/zepp-client/package.json index 5e6965cd09..5c8ac654b6 100644 --- a/packages/zepp-client/package.json +++ b/packages/zepp-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/zepp-client", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Zepp/Amazfit API client using reverse-engineered Huami authentication", "type": "module", "license": "MIT", diff --git a/packages/zones/package.json b/packages/zones/package.json index 9579ca3aeb..9ffec164c5 100644 --- a/packages/zones/package.json +++ b/packages/zones/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/zones", - "version": "0.1.1", + "version": "0.1.4", "description": "Heart-rate and cycling-power zone calculations and classification", "type": "module", "license": "MIT", diff --git a/packages/zwift-client/package.json b/packages/zwift-client/package.json index 28cbed763a..32ddfdfc39 100644 --- a/packages/zwift-client/package.json +++ b/packages/zwift-client/package.json @@ -1,6 +1,6 @@ { "name": "@dofek/zwift", - "version": "0.1.1", + "version": "0.1.4", "description": "Unofficial Zwift API client using reverse-engineered Keycloak authentication", "type": "module", "license": "MIT", diff --git a/paseo.json b/paseo.json deleted file mode 100644 index 94d08b25e6..0000000000 --- a/paseo.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "scripts": { - "server": { - "type": "service", - "command": "PORT=\"$PASEO_PORT\" mise exec -- pnpm --filter dofek-server dev" - }, - "web": { - "type": "service", - "command": "mise exec -- pnpm --filter dofek-web dev --host \"$HOST\" --port \"$PASEO_PORT\"" - }, - "worker": { - "command": "mise exec -- pnpm dev" - }, - "mobile": { - "command": "mise exec -- pnpm --filter dofek-mobile start" - }, - "storybook-web": { - "type": "service", - "command": "mise exec -- pnpm --filter dofek-web storybook -- --host \"$HOST\" --port \"$PASEO_PORT\"" - }, - "storybook-mobile-web": { - "type": "service", - "command": "mise exec -- pnpm --dir packages/mobile exec storybook dev --config-dir .storybook --host \"$HOST\" --port \"$PASEO_PORT\"" - }, - "doctor": { - "command": "mise run doctor" - }, - "test": { - "command": "mise exec -- pnpm test" - } - }, - "worktree": { - "setup": "command -v mise >/dev/null || { echo 'mise is required: https://mise.jdx.dev/getting-started.html' >&2; exit 1; }; export MISE_LOCKED=1; mise install --locked && mise run cloud:prebuild", - "teardown": "mise exec -- pnpm tsx scripts/conductor-archive.ts", - "servicePorts": { - "range": "51000-51999" - } - }, - "metadataGeneration": { - "title": { - "instructions": "Use a concise, imperative title that describes the user-visible outcome." - }, - "branchName": { - "instructions": "Use a short kebab-case branch name based on the change." - }, - "commitMessage": { - "instructions": "Use a Conventional Commit message that states the durable change." - }, - "pullRequest": { - "instructions": "Monitor CI. If a job fails, fix the root cause, commit, push, and keep checking. Monitor for review comments, address all actionable comments, resolve conflicts, and do not stop until CI is green and the PR is ready for review." - } - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60d9e92808..9346cac05e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,8 @@ overrides: lerna>minimatch: 3.1.4 minimatch@3>brace-expansion: 1.1.18 minimatch: 10.2.5 - nanoid@<3.3.17: 3.3.17 + nanoid@<3.3.17: 3.3.18 + image-size: file:vendor/image-size picomatch@>=4.0.0 <4.0.4: 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 @@ -61,11 +62,11 @@ importers: specifier: 5.0.0 version: 5.0.0 '@aws-sdk/client-s3': - specifier: 3.1050.0 - version: 3.1050.0 + specifier: 3.1106.0 + version: 3.1106.0 '@aws-sdk/s3-request-presigner': - specifier: 3.1050.0 - version: 3.1050.0 + specifier: 3.1106.0 + version: 3.1106.0 '@bull-board/api': specifier: 8.1.2 version: 8.1.2(@bull-board/ui@8.1.2) @@ -252,7 +253,7 @@ importers: version: 7.0.0(@testing-library/dom@10.4.1) '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) '@types/archiver': specifier: 8.0.0 version: 8.0.0 @@ -278,8 +279,8 @@ importers: specifier: 10.0.1 version: 10.0.1 cypress: - specifier: 15.18.1 - version: 15.18.1 + specifier: 15.19.0 + version: 15.19.0 dependency-cruiser: specifier: 17.3.10 version: 17.3.10 @@ -530,7 +531,7 @@ importers: version: 57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-router: specifier: 57.0.12 - version: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + version: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) expo-secure-store: specifier: 57.0.1 version: 57.0.1(expo@57.0.12) @@ -557,7 +558,7 @@ importers: version: 19.2.3 react-native: specifier: 0.86.2 - version: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + version: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-body-highlighter: specifier: 3.2.0 version: 3.2.0(react-native@0.86.2)(react@19.2.3) @@ -591,22 +592,22 @@ importers: version: 10.5.3(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4) '@storybook/addon-ondevice-controls': specifier: 10.5.3 - version: 10.5.3(@gorhom/bottom-sheet@5.2.8)(@react-native-community/datetimepicker@9.1.0)(@react-native-community/slider@5.1.2)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + version: 10.5.3(@gorhom/bottom-sheet@5.2.8)(@react-native-community/datetimepicker@9.1.0)(@react-native-community/slider@5.1.2)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@storybook/react-native': specifier: 10.5.3 - version: 10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + version: 10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@storybook/react-native-web-vite': specifier: 10.5.4 - version: 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) + version: 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) '@types/react': - specifier: 19.2.14 - version: 19.2.14 + specifier: 19.2.18 + version: 19.2.18 playwright: - specifier: 1.55.1 - version: 1.55.1 + specifier: 1.62.1 + version: 1.62.1 react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -615,7 +616,7 @@ importers: version: 0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3) storybook: specifier: 10.5.4 - version: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + version: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) typescript: specifier: 6.0.3 version: 6.0.3 @@ -907,10 +908,10 @@ importers: version: link:../zones '@radix-ui/react-dialog': specifier: 1.1.23 - version: 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + version: 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) '@sentry/react': - specifier: 10.45.0 - version: 10.45.0(react@19.2.3) + specifier: 10.69.0 + version: 10.69.0(react@19.2.3) '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@19.2.3) @@ -967,8 +968,8 @@ importers: specifier: 10.5.4 version: 10.5.4(storybook@10.5.4) '@storybook/react-vite': - specifier: 10.5.4 - version: 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) + specifier: 10.5.5 + version: 10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) '@tailwindcss/vite': specifier: 4.3.2 version: 4.3.2(vite@8.0.16) @@ -985,17 +986,17 @@ importers: specifier: 1.9.21 version: 1.9.21 '@types/react': - specifier: 19.2.14 - version: 19.2.14 + specifier: 19.2.18 + version: 19.2.18 '@types/react-dom': specifier: 19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.18) '@vitejs/plugin-react': specifier: 6.0.3 version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.0.16) storybook: specifier: 10.5.4 - version: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + version: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) tailwindcss: specifier: 4.3.2 version: 4.3.2 @@ -1200,13 +1201,6 @@ packages: '@aws-crypto/client-node@5.0.0': resolution: {integrity: sha512-e5jxzQOJZtOeI1aJH/imu0olZ+2dRdh0sWQBOhsHJyWTVyu4YyrhiZaRuOdgP0DLsJdbsKaykf8HFVayvwH/hw==} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - '@aws-crypto/decrypt-node@5.0.0': resolution: {integrity: sha512-YhWXt3k46Z7HaMRqlxyYAqdTwrK2dLPTIg2P2l14txnTxnpUUwziDPm76ahOWBfLZ4dCuFptWnjGCnkCtHm3TQ==} @@ -1243,9 +1237,6 @@ packages: '@aws-crypto/serialize@5.0.0': resolution: {integrity: sha512-9x8f4boWN4UXDJvaK7aocQN7fh9x9wMjQyRTeJqOky7cXnl76EVRvuUh2Mwcel3xKRP86Ac6Hu06yayGDf4ppg==} - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -1259,6 +1250,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.26': + resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-dynamodb@3.1050.0': resolution: {integrity: sha512-KE2rsQUYuHmiNxuJs1IPbFuZcRMpI7anpn7WHEQ3BzzAhh0lXd+47Jgq6SZJSrkt70DjDyoUiuGh7/gKRIRhFg==} engines: {node: '>=20.0.0'} @@ -1267,50 +1262,78 @@ packages: resolution: {integrity: sha512-7k4UPguYBslT34TpI5CbOGlenfrkwDoKfCGV6xDwZI15QwOG3dD2IT3FQncwB7hrlZwRXTuRXSs+8q1m/j4LqQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1050.0': - resolution: {integrity: sha512-9kgtv+bXZQrOIJT2INPPBCezrJu1FlgGrzEat/ut4A4V53IT00LynsBZgp12eFKbjJuNCeTo7iPSKjPsX8ub+A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.974.12': - resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==} + '@aws-sdk/client-s3@3.1106.0': + resolution: {integrity: sha512-hUTlnyRRGlVdfvJLL3hCEnMm7CmunSzc/lxFVRX8g1fjJTMUVbyQCfhfMhp7dZ7JBftLU6OYresu3Hje4nvkJw==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.8': - resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-env@3.972.38': resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.40': resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.42': resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.42': resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.43': resolution: {integrity: sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.38': resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.42': resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.42': resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/dynamodb-codec@3.973.12': resolution: {integrity: sha512-E+qpJPN1QLzfeVDQe1gVmMiHu9PTJWwXqSQjIt8mH5OQXmds2J/IN+Ar6Oa9ZhhuPZb4fPkcgZg4UEpwJM90NA==} engines: {node: '>=20.0.0'} @@ -1319,54 +1342,46 @@ packages: resolution: {integrity: sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.14': - resolution: {integrity: sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-endpoint-discovery@3.972.13': resolution: {integrity: sha512-1r6EkFdSQ4quTP3pW8yWIcYuyDwdwdBxGr+kfuPFYE3DqR+1gBc6NyJneAyoIs+wc/cUfnyJ4ZYC0T2SQTxP9A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.12': - resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-flexible-checksums@3.974.20': - resolution: {integrity: sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-location-constraint@3.972.10': - resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.972.41': - resolution: {integrity: sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-ssec@3.972.10': - resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + '@aws-sdk/middleware-sdk-s3@3.972.72': + resolution: {integrity: sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==} engines: {node: '>=20.0.0'} '@aws-sdk/nested-clients@3.997.10': resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1050.0': - resolution: {integrity: sha512-tTQ+MYyQehtA5SMXnLumFpOMrVBcn88f1k6oyzs4sOQ1Upq72T/BYkKZ+Yn7ejncSsCp3exIVcYwZHFEGKAugQ==} + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1106.0': + resolution: {integrity: sha512-ZI5SCkyz8jB3Qr6NPSJ7R4A/PkniQvbD1DO8APwhMsubFcfwPSJMMUxrkv9k1E20ikRk7skpM9itZh+0wI9K/w==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.27': - resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==} + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.1049.0': resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-dynamodb@3.996.2': resolution: {integrity: sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ==} engines: {node: '>=20.0.0'} @@ -1377,12 +1392,12 @@ packages: resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.24': - resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.29.7': @@ -1405,6 +1420,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} @@ -1502,6 +1521,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} engines: {node: '>=6.9.0'} @@ -2022,10 +2046,18 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@bacons/apple-targets@4.0.7': resolution: {integrity: sha512-DwD8gbz2vjbmLatR5qlWjrTocG/Ku4d2Kz/rIF4yzREPZtVBHMepVPCH7gEEgZw4PBTtWQe3XMGrPkomNp7QUg==} peerDependencies: @@ -3651,9 +3683,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5831,22 +5860,6 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry-internal/browser-utils@10.45.0': - resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==} - engines: {node: '>=18'} - - '@sentry-internal/feedback@10.45.0': - resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==} - engines: {node: '>=18'} - - '@sentry-internal/replay-canvas@10.45.0': - resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==} - engines: {node: '>=18'} - - '@sentry-internal/replay@10.45.0': - resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==} - engines: {node: '>=18'} - '@sentry/babel-plugin-component-annotate@5.3.0': resolution: {integrity: sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==} engines: {node: '>= 18'} @@ -5855,14 +5868,18 @@ packages: resolution: {integrity: sha512-HUzaf0xAnPAB+OHBkD7N1Py+CTbD5InHulQ/pdhX4JctWtxuwD8odMD1LzdPnW8J6gVHlDVvcVBR8mXMZYSLSw==} engines: {node: '>=18'} - '@sentry/browser@10.45.0': - resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==} + '@sentry/browser-utils@10.69.0': + resolution: {integrity: sha512-e/u1Abj0zRPwR/deGZAP3GOULrsx67/XXnM5Skniqs4uxTsdNtPek1Nef0tpxwaQJYxwh6pWdhswLPPbbPOgBQ==} engines: {node: '>=18'} '@sentry/browser@10.67.0': resolution: {integrity: sha512-/ZhsAvte4rYhg0A0RtSFFgAgXhyMOfQIeOAfMfptN+X6IVSYOfkA9jtrP+Ej4+6vlaUFWRir1HweF56y63dEEA==} engines: {node: '>=18'} + '@sentry/browser@10.69.0': + resolution: {integrity: sha512-8391tnm96YbR7b8SYfEA/NEIZuyb2r3SZrtAT0bhZtjlujcYWjo7gugQvk8sWLU9cAa/euD00eJoIoJvNfpd7Q==} + engines: {node: '>=18'} + '@sentry/bundler-plugin-core@5.3.0': resolution: {integrity: sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==} engines: {node: '>= 18'} @@ -5983,6 +6000,10 @@ packages: resolution: {integrity: sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==} engines: {node: '>=18'} + '@sentry/core@10.69.0': + resolution: {integrity: sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==} + engines: {node: '>=18'} + '@sentry/expo-upload-sourcemaps@8.20.0': resolution: {integrity: sha512-FWIJmNTWWY+E/tHQdFIxKMzEB0n47mF8W+1otcXr1hqEsePgBjMqeY2X3NhGRrt4b8rvTCXCjPlc3APyR+nCAg==} engines: {node: '>=18'} @@ -6000,6 +6021,10 @@ packages: resolution: {integrity: sha512-I4ML2/SF3enwikb6ZSoRiqolQrx0zSzTSnUgwCmugICF/jpHW0th1pCray9R+t1Zzibw/Dpj4t/DNXaSDRa2MA==} engines: {node: '>=18'} + '@sentry/feedback@10.69.0': + resolution: {integrity: sha512-qrGz5Qaw93/IhMjlFN6uIaXeHwgHDaKGa6FkTAP6PonpkvSbGGqan6xfsENxzj9HUVoli1lZ6tMRDnt2qtSPhg==} + engines: {node: '>=18'} + '@sentry/node-core@10.45.0': resolution: {integrity: sha512-KQZEvLKM344+EqXiA9HIzWbW5hzq6/9nnFUQ8niaBPoOgR9AiJhrccfIscfgb8vjkriiEtzE03OW/4h1CTgZ3Q==} engines: {node: '>=18'} @@ -6052,14 +6077,14 @@ packages: expo: optional: true - '@sentry/react@10.45.0': - resolution: {integrity: sha512-jLezuxi4BUIU3raKyAPR5xMbQG/nhwnWmKo5p11NCbLmWzkS+lxoyDTUB4B8TAKZLfdtdkKLOn1S0tFc8vbUHw==} + '@sentry/react@10.67.0': + resolution: {integrity: sha512-fS0DplcP9eMxBIRurPC/uxa4NrFK+l9ZsnvQo7wZvNutc7DpTAH0hgFt6laVNCe57s1pFo+OZuKsYBA6JDvH4Q==} engines: {node: '>=18'} peerDependencies: react: 19.2.3 - '@sentry/react@10.67.0': - resolution: {integrity: sha512-fS0DplcP9eMxBIRurPC/uxa4NrFK+l9ZsnvQo7wZvNutc7DpTAH0hgFt6laVNCe57s1pFo+OZuKsYBA6JDvH4Q==} + '@sentry/react@10.69.0': + resolution: {integrity: sha512-f0Il/JMteHjdWPNZQB3rtp1Pcj2Leb3p0KSZuv3rh0EUril9CbWtQVy5zJhoAppi+MWWmgRWa+6BpHbQf+ABQA==} engines: {node: '>=18'} peerDependencies: react: 19.2.3 @@ -6068,10 +6093,18 @@ packages: resolution: {integrity: sha512-neNA4T6MFtZzMdKYetiR+LZd9BNSd0q2szMn0wk+A15PqHE/IN7a34V6JZc9rCtmzB0wldh0eWGOBb49MSNKjA==} engines: {node: '>=18'} + '@sentry/replay-canvas@10.69.0': + resolution: {integrity: sha512-VF6nXvSninHcc7dC1Zme0RjkC7VgRMCixs6jKaQX5zTNeqTW3dZGSefSOVv+ZteRi3hJvVORq985VjUC9Z/0+A==} + engines: {node: '>=18'} + '@sentry/replay@10.67.0': resolution: {integrity: sha512-nkEUgPCR82EcyJkCf3XCE9H0R5KisCqyCAaSGxe7NpAoQbvASHx4MUNgXVAn+D0M494gvPZh6lFH7JgzqTcSqQ==} engines: {node: '>=18'} + '@sentry/replay@10.69.0': + resolution: {integrity: sha512-uRhmNhtFGPOlM0iniVmWKAX3KVXI0le41yYK/iKdPjinT9jA3ZrmykO/Fv1v/KI5znOtwa9D6eHRnDTTMRxFrg==} + engines: {node: '>=18'} + '@sentry/rollup-plugin@5.3.0': resolution: {integrity: sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==} engines: {node: '>= 18'} @@ -6161,18 +6194,26 @@ packages: resolution: {integrity: sha512-ORx3XQryQPq2Jnxv5giSKXVoQRUeylrrymIR2S9fPzLjPcCts8RayMeBSZMcpfpAqp6fnBRuPW2UB6dUPUTEZA==} engines: {node: '>= 20', npm: '>=9.6.4'} - '@smithy/core@3.24.3': - resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.3.3': resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.3': resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -6181,12 +6222,16 @@ packages: resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.3': - resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.2': - resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': @@ -7747,6 +7792,12 @@ packages: storybook: ^10.5.4 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/builder-vite@10.5.5': + resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} + peerDependencies: + storybook: ^10.5.5 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/csf-plugin@10.5.4': resolution: {integrity: sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==} peerDependencies: @@ -7765,6 +7816,24 @@ packages: webpack: optional: true + '@storybook/csf-plugin@10.5.5': + resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} + peerDependencies: + esbuild: 0.28.1 + rollup: '*' + storybook: ^10.5.5 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -7791,6 +7860,20 @@ packages: '@types/react-dom': optional: true + '@storybook/react-dom-shim@10.5.5': + resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.3 + react-dom: 19.2.3 + storybook: ^10.5.5 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@storybook/react-native-theming@10.5.3': resolution: {integrity: sha512-nriL7ZIHVCgYHQNud+xuDKzkQbbOsyvUy/b1gY77NlnudPGVOfGLCKimUpYnG+fH0bej8AX385nXFjBXpuo9sQ==} peerDependencies: @@ -7860,6 +7943,18 @@ packages: typescript: optional: true + '@storybook/react-vite@10.5.5': + resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==} + peerDependencies: + react: 19.2.3 + react-dom: 19.2.3 + storybook: ^10.5.5 + typescript: '>= 4.9.x' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@storybook/react@10.5.4': resolution: {integrity: sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==} peerDependencies: @@ -7877,6 +7972,23 @@ packages: typescript: optional: true + '@storybook/react@10.5.5': + resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.3 + react-dom: 19.2.3 + storybook: ^10.5.5 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + '@stryker-mutator/api@9.6.1': resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} engines: {node: '>=20.0.0'} @@ -8378,8 +8490,8 @@ packages: '@types/react-test-renderer@19.1.0': resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -9850,8 +9962,8 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cypress@15.18.1: - resolution: {integrity: sha512-JtkTVtUE2lvLYgZCaug+Uai0H9IqsJirlBO49c87QwG0bJUGvAUVBz1EJve0b0oaYP244Ew9M0BkrHpcqkYxmw==} + cypress@15.19.0: + resolution: {integrity: sha512-kjy1u3SWlMRWS5qffH3U+bXx1PqPP7qwhIzEY3UtERcW2r/8zy5uk6uSwa75pYXJyYFVP1SO8mAn/avZUvUbfg==} engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} hasBin: true @@ -10891,6 +11003,10 @@ packages: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -10923,13 +11039,6 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fast-xml-builder@1.2.0: - resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} - hasBin: true - fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -11654,8 +11763,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - image-size@1.2.1: - resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + image-size@file:vendor/image-size: + resolution: {directory: vendor/image-size, type: directory} engines: {node: '>=16.x'} hasBin: true @@ -13015,8 +13124,8 @@ packages: nan@2.28.0: resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -13576,10 +13685,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -13710,14 +13815,14 @@ packages: plantuml-encoder@1.4.0: resolution: {integrity: sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==} - playwright-core@1.55.1: - resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.55.1: - resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true plist@3.1.1: @@ -15243,9 +15348,6 @@ packages: '@types/node': optional: true - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} - structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -15299,9 +15401,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - systeminformation@5.31.17: - resolution: {integrity: sha512-TvFA9iwDWlMjqZVlKIJ0Cy+Zgm9ttlMx0SMRwJDMNKyhlEKWBMb3+WRwDi/3dvHdWbexpos4Osp4U49p5WjB5g==} - engines: {node: '>=8.0.0'} + systeminformation@5.33.1: + resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} + engines: {node: '>=10.0.0'} os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true @@ -16385,10 +16487,6 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xml-naming@0.1.0: - resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} - engines: {node: '>=16.0.0'} - xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -16632,18 +16730,6 @@ snapshots: '@aws-crypto/raw-rsa-keyring-node': 5.0.0 tslib: 2.8.1 - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 - tslib: 2.8.1 - - '@aws-crypto/crc32c@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 - tslib: 2.8.1 - '@aws-crypto/decrypt-node@5.0.0': dependencies: '@aws-crypto/material-management-node': 5.0.0 @@ -16728,15 +16814,6 @@ snapshots: tslib: 2.8.1 uuid: 11.1.1 - '@aws-crypto/sha1-browser@5.2.0': - dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-locate-window': 3.965.5 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -16750,7 +16827,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.2 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -16759,96 +16836,110 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.2 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/checksums@3.1000.26': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/client-dynamodb@3.1050.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/credential-provider-node': 3.972.43 '@aws-sdk/dynamodb-codec': 3.973.12 '@aws-sdk/middleware-endpoint-discovery': 3.972.13 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 '@smithy/fetch-http-handler': 5.4.3 '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/client-kms@3.1050.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/credential-provider-node': 3.972.43 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 '@smithy/fetch-http-handler': 5.4.3 '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1050.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.12 - '@aws-sdk/credential-provider-node': 3.972.43 - '@aws-sdk/middleware-bucket-endpoint': 3.972.14 - '@aws-sdk/middleware-expect-continue': 3.972.12 - '@aws-sdk/middleware-flexible-checksums': 3.974.20 - '@aws-sdk/middleware-location-constraint': 3.972.10 - '@aws-sdk/middleware-sdk-s3': 3.972.41 - '@aws-sdk/middleware-ssec': 3.972.10 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/fetch-http-handler': 5.4.3 - '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@aws-sdk/client-s3@3.1106.0': + dependencies: + '@aws-sdk/checksums': 3.1000.26 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/middleware-sdk-s3': 3.972.72 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/core@3.974.12': + '@aws-sdk/core@3.977.6': dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.24 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.8': + '@aws-sdk/credential-provider-env@3.972.38': dependencies: - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.38': + '@aws-sdk/credential-provider-env@3.972.67': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.972.40': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 '@smithy/fetch-http-handler': 5.4.3 '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.972.42': dependencies: - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/credential-provider-env': 3.972.38 '@aws-sdk/credential-provider-http': 3.972.40 '@aws-sdk/credential-provider-login': 3.972.42 @@ -16856,19 +16947,44 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.972.42 '@aws-sdk/credential-provider-web-identity': 3.972.42 '@aws-sdk/nested-clients': 3.997.10 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.3.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.42': dependencies: - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/nested-clients': 3.997.10 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-node@3.972.43': @@ -16879,150 +16995,175 @@ snapshots: '@aws-sdk/credential-provider-process': 3.972.38 '@aws-sdk/credential-provider-sso': 3.972.42 '@aws-sdk/credential-provider-web-identity': 3.972.42 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.3.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.38': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.972.42': dependencies: - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/nested-clients': 3.997.10 '@aws-sdk/token-providers': 3.1049.0 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.972.42': dependencies: - '@aws-sdk/core': 3.974.12 + '@aws-sdk/core': 3.977.6 '@aws-sdk/nested-clients': 3.997.10 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/dynamodb-codec@3.973.12': + '@aws-sdk/credential-provider-web-identity@3.972.73': dependencies: - '@aws-sdk/core': 3.974.12 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/endpoint-cache@3.972.5': + '@aws-sdk/dynamodb-codec@3.973.12': dependencies: - mnemonist: 0.38.3 + '@aws-sdk/core': 3.977.6 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.14': + '@aws-sdk/endpoint-cache@3.972.5': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + mnemonist: 0.38.3 tslib: 2.8.1 '@aws-sdk/middleware-endpoint-discovery@3.972.13': dependencies: '@aws-sdk/endpoint-cache': 3.972.5 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.12': + '@aws-sdk/middleware-sdk-s3@3.972.72': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.20': + '@aws-sdk/nested-clients@3.997.10': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.12 - '@aws-sdk/crc64-nvme': 3.972.8 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.10': + '@aws-sdk/nested-clients@3.997.41': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.41': + '@aws-sdk/s3-request-presigner@3.1106.0': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.10': + '@aws-sdk/signature-v4-multi-region@3.996.43': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.10': + '@aws-sdk/token-providers@3.1049.0': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.12 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/fetch-http-handler': 5.4.3 - '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1050.0': + '@aws-sdk/token-providers@3.1103.0': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.27': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.1049.0': + '@aws-sdk/types@3.973.8': dependencies: - '@aws-sdk/core': 3.974.12 - '@aws-sdk/nested-clients': 3.997.10 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/types@3.973.8': + '@aws-sdk/types@3.974.2': dependencies: - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1050.0)': @@ -17034,14 +17175,12 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.24': + '@aws-sdk/xml-builder@3.972.37': dependencies: - '@nodable/entities': 2.1.0 - '@smithy/types': 4.14.2 - fast-xml-parser: 5.7.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.7': dependencies: @@ -17054,14 +17193,14 @@ snapshots: '@babel/core@7.29.7(supports-color@11.0.0)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@11.0.0) @@ -17074,7 +17213,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -17082,7 +17221,15 @@ snapshots: '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -17107,7 +17254,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@11.0.0) - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -17134,15 +17281,15 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7(supports-color@11.0.0)': dependencies: - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.29.7(supports-color@11.0.0)': dependencies: - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -17151,13 +17298,13 @@ snapshots: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-module-imports': 7.29.7(supports-color@11.0.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-plugin-utils@7.29.7': {} @@ -17166,7 +17313,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-wrap-function': 7.29.7(supports-color@11.0.0) - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17181,8 +17328,8 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@11.0.0)': dependencies: - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -17195,29 +17342,33 @@ snapshots: '@babel/helper-wrap-function@7.29.7(supports-color@11.0.0)': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.3': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)(supports-color@11.0.0)': dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17252,7 +17403,7 @@ snapshots: dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17340,7 +17491,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17401,7 +17552,7 @@ snapshots: dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17472,7 +17623,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17518,7 +17669,7 @@ snapshots: '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17558,7 +17709,7 @@ snapshots: '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17639,7 +17790,7 @@ snapshots: '@babel/helper-module-imports': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -17820,7 +17971,7 @@ snapshots: dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 esutils: 2.0.3 '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)(supports-color@11.0.0)': @@ -17843,13 +17994,13 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.7(supports-color@11.0.0)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -17858,11 +18009,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8(supports-color@11.0.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@11.0.0) + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bacons/apple-targets@4.0.7(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@bacons/xcode': 1.0.0-alpha.32(supports-color@11.0.0) @@ -18485,8 +18653,8 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -18553,13 +18721,13 @@ snapshots: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@expo/dom-webview@55.0.5(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@expo/env@2.4.2(supports-color@11.0.0)': dependencies: @@ -18639,14 +18807,14 @@ snapshots: anser: 1.4.10 expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) stacktrace-parser: 0.1.11 '@expo/metro-config@57.0.8(expo@57.0.12)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@expo/config': 57.0.7(supports-color@11.0.0)(typescript@6.0.3) '@expo/env': 2.4.2(supports-color@11.0.0) '@expo/json-file': 11.0.1 @@ -18693,7 +18861,7 @@ snapshots: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -18781,7 +18949,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) - expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) + expo-router: 57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -18796,13 +18964,13 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)': + '@expo/ui@57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)': dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) sf-symbols-typescript: 2.2.0 - vaul: 1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + vaul: 1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7(supports-color@11.0.0) react-dom: 19.2.3(react@19.2.3) @@ -18815,7 +18983,7 @@ snapshots: dependencies: expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@expo/ws-tunnel@2.0.0(ws@8.21.0)': dependencies: @@ -18880,22 +19048,22 @@ snapshots: '@garmin/fitsdk@21.208.0': {} - '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3)': + '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.18)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.86.2)(react@19.2.3) invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-gesture-handler: 2.32.0(react-native@0.86.2)(react@19.2.3) react-native-reanimated: 4.5.1(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 '@gorhom/portal@1.0.14(react-native@0.86.2)(react@19.2.3)': dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@grpc/grpc-js@1.14.4': dependencies: @@ -19541,8 +19709,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nodable/entities@2.1.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -20377,7 +20543,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1)(supports-color@11.0.0) - systeminformation: 5.31.17 + systeminformation: 5.33.1 transitivePeerDependencies: - supports-color @@ -21243,274 +21409,274 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) aria-hidden: 1.2.6 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 '@react-native-async-storage/async-storage@2.2.0(react-native@0.86.2)': dependencies: merge-options: 3.0.4 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@react-native-community/datetimepicker@9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)': dependencies: invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) @@ -21519,13 +21685,13 @@ snapshots: '@react-native-masked-view/masked-view@0.3.2(react-native@0.86.2)(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) '@react-native/assets-registry@0.86.2': {} '@react-native/babel-plugin-codegen@0.86.2(@babel/core@7.29.7)(supports-color@11.0.0)': dependencies: - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) '@react-native/codegen': 0.86.2(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' @@ -21655,14 +21821,14 @@ snapshots: '@react-native/normalize-colors@0.86.2': {} - '@react-native/virtualized-lists@0.86.2(@types/react@19.2.14)(react-native@0.86.2)(react@19.2.3)': + '@react-native/virtualized-lists@0.86.2(@types/react@19.2.18)(react-native@0.86.2)(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 '@redocly/cli@2.39.0': {} @@ -21888,24 +22054,6 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry-internal/browser-utils@10.45.0': - dependencies: - '@sentry/core': 10.45.0 - - '@sentry-internal/feedback@10.45.0': - dependencies: - '@sentry/core': 10.45.0 - - '@sentry-internal/replay-canvas@10.45.0': - dependencies: - '@sentry-internal/replay': 10.45.0 - '@sentry/core': 10.45.0 - - '@sentry-internal/replay@10.45.0': - dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry/core': 10.45.0 - '@sentry/babel-plugin-component-annotate@5.3.0': {} '@sentry/browser-utils@10.67.0': @@ -21913,13 +22061,10 @@ snapshots: '@sentry/conventions': 0.16.0 '@sentry/core': 10.67.0 - '@sentry/browser@10.45.0': + '@sentry/browser-utils@10.69.0': dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry-internal/feedback': 10.45.0 - '@sentry-internal/replay': 10.45.0 - '@sentry-internal/replay-canvas': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.69.0 '@sentry/browser@10.67.0': dependencies: @@ -21930,6 +22075,15 @@ snapshots: '@sentry/replay': 10.67.0 '@sentry/replay-canvas': 10.67.0 + '@sentry/browser@10.69.0': + dependencies: + '@sentry/browser-utils': 10.69.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.69.0 + '@sentry/feedback': 10.69.0 + '@sentry/replay': 10.69.0 + '@sentry/replay-canvas': 10.69.0 + '@sentry/bundler-plugin-core@5.3.0(encoding@0.1.13)(supports-color@11.0.0)': dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) @@ -22035,6 +22189,10 @@ snapshots: dependencies: '@sentry/conventions': 0.16.0 + '@sentry/core@10.69.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/expo-upload-sourcemaps@8.20.0(@expo/env@2.4.2)(dotenv@16.6.1)': dependencies: '@sentry/cli': 3.6.1 @@ -22046,6 +22204,10 @@ snapshots: dependencies: '@sentry/core': 10.67.0 + '@sentry/feedback@10.69.0': + dependencies: + '@sentry/core': 10.69.0 + '@sentry/node-core@10.45.0(@opentelemetry/api@1.9.1)(@opentelemetry/context-async-hooks@2.10.0)(@opentelemetry/core@2.9.0)(@opentelemetry/instrumentation@0.213.0)(@opentelemetry/resources@2.10.0)(@opentelemetry/sdk-trace-base@2.10.0)(@opentelemetry/semantic-conventions@1.43.0)': dependencies: '@sentry/core': 10.45.0 @@ -22118,19 +22280,13 @@ snapshots: '@sentry/expo-upload-sourcemaps': 8.20.0(@expo/env@2.4.2)(dotenv@16.6.1) '@sentry/react': 10.67.0(react@19.2.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) transitivePeerDependencies: - '@expo/env' - dotenv - '@sentry/react@10.45.0(react@19.2.3)': - dependencies: - '@sentry/browser': 10.45.0 - '@sentry/core': 10.45.0 - react: 19.2.3 - '@sentry/react@10.67.0(react@19.2.3)': dependencies: '@sentry/browser': 10.67.0 @@ -22138,16 +22294,33 @@ snapshots: '@sentry/core': 10.67.0 react: 19.2.3 + '@sentry/react@10.69.0(react@19.2.3)': + dependencies: + '@sentry/browser': 10.69.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.69.0 + react: 19.2.3 + '@sentry/replay-canvas@10.67.0': dependencies: '@sentry/core': 10.67.0 '@sentry/replay': 10.67.0 + '@sentry/replay-canvas@10.69.0': + dependencies: + '@sentry/core': 10.69.0 + '@sentry/replay': 10.69.0 + '@sentry/replay@10.67.0': dependencies: '@sentry/browser-utils': 10.67.0 '@sentry/core': 10.67.0 + '@sentry/replay@10.69.0': + dependencies: + '@sentry/browser-utils': 10.69.0 + '@sentry/core': 10.69.0 + '@sentry/rollup-plugin@5.3.0(encoding@0.1.13)(rollup@4.62.2)(supports-color@11.0.0)': dependencies: '@sentry/bundler-plugin-core': 5.3.0(encoding@0.1.13)(supports-color@11.0.0) @@ -22262,22 +22435,33 @@ snapshots: p-retry: 4.6.2 retry: 0.13.1 - '@smithy/core@3.24.3': + '@smithy/core@3.31.1': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.2 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/credential-provider-imds@4.3.3': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/fetch-http-handler@5.4.3': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -22286,17 +22470,23 @@ snapshots: '@smithy/node-http-handler@4.7.3': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/signature-v4@5.4.3': + '@smithy/signature-v4@5.6.12': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/types@4.14.2': + '@smithy/types@4.16.1': dependencies: tslib: 2.8.1 @@ -24424,31 +24614,31 @@ snapshots: dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.4 - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) '@storybook/addon-ondevice-actions@10.5.3(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)': dependencies: '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) fast-deep-equal: 3.1.3 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) - '@storybook/addon-ondevice-controls@10.5.3(@gorhom/bottom-sheet@5.2.8)(@react-native-community/datetimepicker@9.1.0)(@react-native-community/slider@5.1.2)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + '@storybook/addon-ondevice-controls@10.5.3(@gorhom/bottom-sheet@5.2.8)(@react-native-community/datetimepicker@9.1.0)(@react-native-community/slider@5.1.2)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.86.2)(react@19.2.3) '@react-native-community/datetimepicker': 9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@react-native-community/slider': 5.1.2 '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) - '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) polished: 4.3.1 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-modal-datetime-picker: 18.0.0(@react-native-community/datetimepicker@9.1.0)(react-native@0.86.2) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) tinycolor2: 1.6.0 optionalDependencies: - '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) + '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.18)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -24459,7 +24649,18 @@ snapshots: '@storybook/builder-vite@10.5.4(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16)': dependencies: '@storybook/csf-plugin': 10.5.4(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + ts-dedent: 2.3.0 + vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + + '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16)': + dependencies: + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) ts-dedent: 2.3.0 vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: @@ -24469,7 +24670,16 @@ snapshots: '@storybook/csf-plugin@10.5.4(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16)': dependencies: - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.1 + rollup: 4.62.2 + vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + + '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16)': + dependencies: + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 @@ -24493,31 +24703,40 @@ snapshots: - '@tmcp/auth' - typescript - '@storybook/react-dom-shim@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)': + '@storybook/react-dom-shim@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)': dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) + + '@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) '@storybook/react-native-theming@10.5.3(react-native@0.86.2)(react@19.2.3)': dependencies: polished: 4.3.1 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) - '@storybook/react-native-ui-common@10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + '@storybook/react-native-ui-common@10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@nozbe/microfuzz': 1.0.0 - '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) es-toolkit: 1.50.0 memoizerific: 1.11.3 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) ts-dedent: 2.3.0 transitivePeerDependencies: - '@types/react' @@ -24526,22 +24745,22 @@ snapshots: - supports-color - typescript - '@storybook/react-native-ui@10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + '@storybook/react-native-ui@10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) + '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.18)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) '@gorhom/portal': 1.0.14(react-native@0.86.2)(react@19.2.3) '@nozbe/microfuzz': 1.0.0 - '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) - '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) polished: 4.3.1 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-gesture-handler: 2.32.0(react-native@0.86.2)(react@19.2.3) react-native-reanimated: 4.5.1(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) react-native-safe-area-context: 5.7.0(react-native@0.86.2)(react@19.2.3) react-native-svg: 15.15.4(react-native@0.86.2)(react@19.2.3) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -24549,16 +24768,16 @@ snapshots: - supports-color - typescript - '@storybook/react-native-web-vite@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16)': + '@storybook/react-native-web-vite@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16)': dependencies: '@storybook/builder-vite': 10.5.4(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16) - '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) - '@storybook/react-vite': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react-vite': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) vite-plugin-rnw: 0.0.11(react-native-web@0.21.2)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) vite-tsconfig-paths: 6.1.1(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16) @@ -24571,13 +24790,13 @@ snapshots: - typescript - webpack - '@storybook/react-native@10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + '@storybook/react-native@10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@storybook/mcp': 0.8.0(typescript@6.0.3) - '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@storybook/react-native-theming': 10.5.3(react-native@0.86.2)(react@19.2.3) - '@storybook/react-native-ui': 10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) - '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react-native-ui': 10.5.3(@gorhom/bottom-sheet@5.2.8)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-svg@15.15.4)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react-native-ui-common': 10.5.3(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) '@tmcp/adapter-valibot': 0.1.6(tmcp@1.19.4)(valibot@1.4.2) '@tmcp/transport-http': 0.8.6(tmcp@1.19.4) commander: 14.0.3 @@ -24586,16 +24805,16 @@ snapshots: esbuild-register: 3.6.0(esbuild@0.28.1)(supports-color@11.0.0) glob: 13.0.6 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-safe-area-context: 5.7.0(react-native@0.86.2)(react@19.2.3) react-native-url-polyfill: 3.0.0(react-native@0.86.2) setimmediate: 1.0.5 - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) tmcp: 1.19.4(typescript@6.0.3) valibot: 1.4.2(typescript@6.0.3) ws: 8.21.0 optionalDependencies: - '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) + '@gorhom/bottom-sheet': 5.2.8(@types/react@19.2.18)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) react-native-gesture-handler: 2.32.0(react-native@0.86.2)(react@19.2.3) react-native-reanimated: 4.5.1(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) transitivePeerDependencies: @@ -24611,19 +24830,19 @@ snapshots: - typescript - utf-8-validate - '@storybook/react-vite@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16)': + '@storybook/react-vite@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16)': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.16) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) '@storybook/builder-vite': 10.5.4(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16) - '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.3 react-docgen: 8.0.3(supports-color@11.0.0) react-dom: 19.2.3(react@19.2.3) resolve: 1.22.12 - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) tsconfig-paths: 4.2.0 vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) optionalDependencies: @@ -24636,18 +24855,59 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + '@storybook/react-vite@10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.3)(react@19.2.3)(rollup@4.62.2)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)(vite@8.0.16)': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.16) + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.5.4)(vite@8.0.16) + '@storybook/react': 10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.3 + react-docgen: 8.0.3(supports-color@11.0.0) + react-dom: 19.2.3(react@19.2.3) + resolve: 1.22.12 + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + tsconfig-paths: 4.2.0 + vite: 8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - webpack + + '@storybook/react@10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4) + '@storybook/react-dom-shim': 10.5.4(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4) react: 19.2.3 react-docgen: 8.0.3(supports-color@11.0.0) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.3(react@19.2.3) - storybook: 10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@storybook/react@10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4)(supports-color@11.0.0)(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)(storybook@10.5.4) + react: 19.2.3 + react-docgen: 8.0.3(supports-color@11.0.0) + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.3(react@19.2.3) + storybook: 10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -24962,15 +25222,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3)': dependencies: '@babel/runtime': 7.28.6 '@testing-library/dom': 10.4.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.3(@types/react@19.2.18) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -25055,24 +25315,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/body-parser@1.19.6': dependencies: @@ -25239,15 +25499,15 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 '@types/react-test-renderer@19.1.0': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - '@types/react@19.2.14': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -25891,7 +26151,7 @@ snapshots: babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 babel-plugin-react-native-web@0.21.2: {} @@ -25917,7 +26177,7 @@ snapshots: babel-preset-expo@57.0.6(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.12)(react-refresh@0.14.2)(supports-color@11.0.0): dependencies: - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-module-imports': 7.29.7(supports-color@11.0.0) '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7)(supports-color@11.0.0) '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) @@ -26968,7 +27228,7 @@ snapshots: csstype@3.2.3: {} - cypress@15.18.1: + cypress@15.19.0: dependencies: '@cypress/request': 4.0.1 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) @@ -27003,7 +27263,7 @@ snapshots: proxy-from-env: 1.0.0 request-progress: 3.0.0 supports-color: 8.1.1 - systeminformation: 5.31.17 + systeminformation: 5.33.1 tmp: 0.2.7 tree-kill: 1.2.2 tslib: 1.14.1 @@ -27761,7 +28021,7 @@ snapshots: dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-application@57.0.2(expo@57.0.12): dependencies: @@ -27773,7 +28033,7 @@ snapshots: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript @@ -27790,7 +28050,7 @@ snapshots: barcode-detector: 3.1.3(@types/emscripten@1.41.5) expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3) transitivePeerDependencies: @@ -27800,7 +28060,7 @@ snapshots: dependencies: '@expo/env': 2.4.2(supports-color@11.0.0) expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -27808,7 +28068,7 @@ snapshots: dependencies: '@expo/env': 2.4.2(supports-color@11.0.0) expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -27833,7 +28093,7 @@ snapshots: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-dev-menu: 57.0.11(expo@57.0.12)(react-native@0.86.2) expo-manifests: 57.0.1(expo@57.0.12) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-dev-menu-interface@57.0.0(expo@57.0.12): dependencies: @@ -27843,7 +28103,7 @@ snapshots: dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-dev-menu-interface: 57.0.0(expo@57.0.12) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-document-picker@57.0.1(expo@57.0.12): dependencies: @@ -27854,20 +28114,20 @@ snapshots: expo-file-system@57.0.2(expo@57.0.12)(react-native@0.86.2): dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-font@57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-glass-effect@57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3): dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-haptics@57.0.1(expo@57.0.12): dependencies: @@ -27885,7 +28145,7 @@ snapshots: expo-constants: 57.0.9(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - expo - supports-color @@ -27919,7 +28179,7 @@ snapshots: expo-modules-jsi: 57.0.4(react-native@0.86.2) invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) @@ -27929,13 +28189,13 @@ snapshots: expo-modules-jsi: 57.0.4(react-native@0.86.2) invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) expo-modules-jsi@57.0.4(react-native@0.86.2): dependencies: - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo-notifications@57.0.10(expo@57.0.12)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: @@ -27946,19 +28206,19 @@ snapshots: expo-application: 57.0.2(expo@57.0.12) expo-constants: 57.0.10(expo@57.0.12)(react-native@0.86.2)(supports-color@11.0.0) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - expo-router@57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): + expo-router@57.0.12(@babel/core@7.29.7)(@expo/log-box@57.0.2)(@expo/metro-runtime@57.0.9)(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo-constants@57.0.10)(expo-font@57.0.1)(expo-linking@57.0.5)(expo@57.0.12)(react-dom@19.2.3)(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native-safe-area-context@5.7.0)(react-native-screens@4.26.2)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0): dependencies: '@expo/log-box': 57.0.2(@expo/dom-webview@55.0.5)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) '@expo/metro-runtime': 57.0.9(@expo/log-box@57.0.2)(expo@57.0.12)(react-dom@19.2.3)(react-native@0.86.2)(react@19.2.3) '@expo/schema-utils': 57.0.2 - '@expo/ui': 57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.14)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + '@expo/ui': 57.0.10(@babel/core@7.29.7)(@types/react-dom@19.2.3)(@types/react@19.2.18)(expo@57.0.12)(react-dom@19.2.3)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.2)(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) @@ -27974,12 +28234,12 @@ snapshots: expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 - nanoid: 3.3.17 + nanoid: 3.3.18 query-string: 7.1.3 react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.6 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-drawer-layout: 4.2.5(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3) react-native-safe-area-context: 5.7.0(react-native@0.86.2)(react@19.2.3) react-native-screens: 4.26.2(react-native@0.86.2)(react@19.2.3) @@ -27987,7 +28247,7 @@ snapshots: sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + vaul: 1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) react-native-gesture-handler: 2.32.0(react-native@0.86.2)(react@19.2.3) @@ -28015,7 +28275,7 @@ snapshots: '@expo/plist': 0.8.1(patch_hash=2e81ea41c32856973b677ca39a3f7f4a53b550dc5093fddfaa1abd120b800e2e) expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript @@ -28038,13 +28298,13 @@ snapshots: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) expo-font: 57.0.1(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) sf-symbols-typescript: 2.2.0 expo-task-manager@57.0.9(expo@57.0.12)(react-native@0.86.2): dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) unimodules-app-loader: 57.0.1 expo-updates-interface@57.0.1(expo@57.0.12): @@ -28069,7 +28329,7 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) resolve-from: 5.0.0 optionalDependencies: expo-dev-client: 57.0.11(expo@57.0.12)(react-native@0.86.2) @@ -28079,7 +28339,7 @@ snapshots: expo-web-browser@57.0.2(expo@57.0.12)(react-native@0.86.2): dependencies: expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) expo@57.0.12(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3)(react-native-web@0.21.2)(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0)(typescript@6.0.3): dependencies: @@ -28104,7 +28364,7 @@ snapshots: expo-modules-core: 57.0.10(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: @@ -28176,6 +28436,8 @@ snapshots: extsprintf@1.3.0: {} + extsprintf@1.4.1: {} + fast-deep-equal@3.1.3: {} fast-equals@6.0.2: {} @@ -28206,18 +28468,6 @@ snapshots: dependencies: fast-string-width: 3.0.2 - fast-xml-builder@1.2.0: - dependencies: - path-expression-matcher: 1.5.0 - xml-naming: 0.1.0 - - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.3.0 - fastest-levenshtein@1.0.16: {} fastq@1.20.1: @@ -29036,7 +29286,7 @@ snapshots: ignore@7.0.5: {} - image-size@1.2.1: + image-size@file:vendor/image-size: dependencies: queue: 6.0.2 @@ -30365,9 +30615,9 @@ snapshots: metro-transform-plugins@0.84.4(supports-color@11.0.0): dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -30376,9 +30626,9 @@ snapshots: metro-transform-worker@0.84.4(supports-color@11.0.0): dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 metro: 0.84.4(supports-color@11.0.0) metro-babel-transformer: 0.84.4(supports-color@11.0.0) @@ -30397,11 +30647,11 @@ snapshots: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 connect: 3.7.0(supports-color@11.0.0) @@ -30410,7 +30660,7 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 hermes-parser: 0.35.0 - image-size: 1.2.1 + image-size: file:vendor/image-size invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 @@ -30670,7 +30920,7 @@ snapshots: nan@2.28.0: optional: true - nanoid@3.3.17: {} + nanoid@3.3.18: {} nanospinner@1.2.2: dependencies: @@ -31558,8 +31808,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-is-absolute@1.0.1: {} path-key@2.0.1: {} @@ -31659,11 +31907,11 @@ snapshots: plantuml-encoder@1.4.0: {} - playwright-core@1.55.1: {} + playwright-core@1.62.1: {} - playwright@1.55.1: + playwright@1.62.1: dependencies: - playwright-core: 1.55.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -31696,7 +31944,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -31971,8 +32219,8 @@ snapshots: react-docgen@8.0.3(supports-color@11.0.0): dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -32005,14 +32253,14 @@ snapshots: react-native-body-highlighter@3.2.0(react-native@0.86.2)(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-svg: 15.15.4(react-native@0.86.2)(react@19.2.3) react-native-drawer-layout@4.2.5(react-native-gesture-handler@2.32.0)(react-native-reanimated@4.5.1)(react-native@0.86.2)(react@19.2.3): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-gesture-handler: 2.32.0(react-native@0.86.2)(react@19.2.3) react-native-reanimated: 4.5.1(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) @@ -32024,18 +32272,18 @@ snapshots: hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-is-edge-to-edge@1.3.1(react-native@0.86.2)(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-maps@1.27.2(react-native-web@0.21.2)(react-native@0.86.2)(react@19.2.3): dependencies: '@types/geojson': 7946.0.16 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) optionalDependencies: react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3) @@ -32043,12 +32291,12 @@ snapshots: dependencies: '@react-native-community/datetimepicker': 9.1.0(expo@57.0.12)(react-native@0.86.2)(react@19.2.3) prop-types: 15.8.1 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-reanimated@4.5.1(react-native-worklets@0.10.1)(react-native@0.86.2)(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-is-edge-to-edge: 1.3.1(react-native@0.86.2)(react@19.2.3) react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(react-native@0.86.2)(react@19.2.3)(supports-color@11.0.0) semver: 7.8.5 @@ -32056,13 +32304,13 @@ snapshots: react-native-safe-area-context@5.7.0(react-native@0.86.2)(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) react-native-screens@4.26.2(react-native@0.86.2)(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) warn-once: 0.1.1 react-native-svg@15.15.4(react-native@0.86.2)(react@19.2.3): @@ -32070,12 +32318,12 @@ snapshots: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) warn-once: 0.1.1 react-native-url-polyfill@3.0.0(react-native@0.86.2): dependencies: - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) whatwg-url-without-unicode: 8.0.0-3 react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.3)(react@19.2.3): @@ -32109,12 +32357,12 @@ snapshots: '@react-native/metro-config': 0.86.2(@babel/core@7.29.7)(supports-color@11.0.0) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0): + react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.18)(react@19.2.3)(supports-color@11.0.0): dependencies: '@react-native/assets-registry': 0.86.2 '@react-native/codegen': 0.86.2(@babel/core@7.29.7) @@ -32122,7 +32370,7 @@ snapshots: '@react-native/gradle-plugin': 0.86.2 '@react-native/js-polyfills': 0.86.2 '@react-native/normalize-colors': 0.86.2 - '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.14)(react-native@0.86.2)(react@19.2.3) + '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.18)(react-native@0.86.2)(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -32150,7 +32398,7 @@ snapshots: ws: 8.21.0 yargs: 17.7.3 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 transitivePeerDependencies: - '@babel/core' - '@react-native-community/cli' @@ -32163,37 +32411,37 @@ snapshots: react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3): + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.3): dependencies: react: 19.2.3 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.3): + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.3): dependencies: react: 19.2.3 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.3) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.3) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.3) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.3) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 react-spinners@0.17.0(react-dom@19.2.3)(react@19.2.3): dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.3): + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.3): dependencies: get-nonce: 1.0.1 react: 19.2.3 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 react@19.2.3: {} @@ -33165,7 +33413,7 @@ snapshots: stoppable@1.1.0: {} - storybook@10.5.4(@types/react@19.2.14)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3): + storybook@10.5.4(@types/react@19.2.18)(prettier@3.9.5)(react-dom@19.2.3)(react@19.2.3): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.3)(react@19.2.3) @@ -33185,7 +33433,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.3) ws: 8.21.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 prettier: 3.9.5 transitivePeerDependencies: - bufferutil @@ -33373,8 +33621,6 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 - strnum@2.3.0: {} - structured-headers@0.4.1: {} stylelint@17.14.0(supports-color@11.0.0)(typescript@6.0.3): @@ -33454,7 +33700,7 @@ snapshots: symbol-tree@3.2.4: {} - systeminformation@5.31.17: {} + systeminformation@5.33.1: {} table@5.4.6: dependencies: @@ -33986,24 +34232,24 @@ snapshots: dependencies: prepend-http: 2.0.0 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.3): + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.3): dependencies: react: 19.2.3 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 use-latest-callback@0.2.6(react@19.2.3): dependencies: react: 19.2.3 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.3): + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.3): dependencies: detect-node-es: 1.1.0 react: 19.2.3 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.18 use-sync-external-store@1.6.0(react@19.2.3): dependencies: @@ -34046,9 +34292,9 @@ snapshots: vary@1.1.2: {} - vaul@1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3): + vaul@1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3): dependencies: - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.3)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: @@ -34059,7 +34305,7 @@ snapshots: dependencies: assert-plus: 1.0.0 core-util-is: 1.0.2 - extsprintf: 1.3.0 + extsprintf: 1.4.1 vinyl-file@3.0.0: dependencies: @@ -34678,8 +34924,6 @@ snapshots: xml-name-validator@5.0.0: {} - xml-naming@0.1.0: {} - xml2js@0.6.0: dependencies: sax: 1.6.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 597d966322..53feb80e51 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,7 +58,8 @@ overrides: "lerna>minimatch": 3.1.4 "minimatch@3>brace-expansion": 1.1.18 minimatch: 10.2.5 - "nanoid@<3.3.17": 3.3.17 + "nanoid@<3.3.17": 3.3.18 + image-size: "file:vendor/image-size" "picomatch@>=4.0.0 <4.0.4": 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 @@ -133,6 +134,7 @@ minimumReleaseAgeExclude: - '@expo/config-plugins@57.0.7' - expo-file-system@57.0.2 - expo-symbols@57.0.2 + - nanoid@3.3.18 - '@expo/fingerprint@0.20.7' - '@expo/metro-config@57.0.8' - expo-server@57.0.2 diff --git a/src/db/canonical-activity-types-migration.integration.test.ts b/src/db/canonical-activity-types-migration.integration.test.ts index 990688fd3c..8019211d95 100644 --- a/src/db/canonical-activity-types-migration.integration.test.ts +++ b/src/db/canonical-activity-types-migration.integration.test.ts @@ -15,7 +15,7 @@ import { runMigrations } from "./migrate.ts"; import { writeTestMigrationFiles } from "./test-helpers.ts"; import { executeWithSchema, type SchemaExecutionDatabase } from "./typed-sql.ts"; -// cspell:ignore conrelid contype functional_fitness relacl relkind relname relnamespace relreplident +// cspell:ignore conrelid contype enumlabel enumtypid functional_fitness regtype relacl relkind relname relnamespace relreplident typname typnamespace const activityIds = { football: "00000000-0000-4000-8000-000000000105", @@ -523,15 +523,48 @@ describe("canonical activity types Postgres migration", () => { join(import.meta.dirname, "../../drizzle/0068_canonical_activity_types.sql"), "utf8", ); + const hangboardMigrationContent = readFileSync( + join(import.meta.dirname, "../../drizzle/0072_add_hangboard_activity_type.sql"), + "utf8", + ); writeTestMigrationFiles(migrationDirectory, [ { content: migrationContent, file: "0068_canonical_activity_types.sql", when: 2_100_000_000_000, }, + { + content: hangboardMigrationContent, + file: "0072_add_hangboard_activity_type.sql", + when: 2_100_000_000_001, + }, ]); - expect(await runMigrations(connectionString, migrationDirectory)).toBe(1); + expect(await runMigrations(connectionString, migrationDirectory)).toBe(2); + + const hangboardTypeState = await executeWithSchema( + database, + z.object({ + has_hangboard: z.boolean(), + legacy_activity_type_removed: z.boolean(), + }), + sql` + SELECT + EXISTS ( + SELECT 1 + FROM pg_enum + INNER JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'fitness' + AND pg_type.typname = 'canonical_activity_type' + AND pg_enum.enumlabel = 'hangboard' + ) AS has_hangboard, + to_regtype('fitness.activity_type') IS NULL AS legacy_activity_type_removed + `, + ); + expect(hangboardTypeState).toEqual([ + { has_hangboard: true, legacy_activity_type_removed: true }, + ]); const identityAfter = await executeWithSchema( database, diff --git a/src/db/hangboard-activity-type-migration.integration.test.ts b/src/db/hangboard-activity-type-migration.integration.test.ts new file mode 100644 index 0000000000..0907d88082 --- /dev/null +++ b/src/db/hangboard-activity-type-migration.integration.test.ts @@ -0,0 +1,33 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { setupTestDatabase, type TestContext } from "./test-helpers.ts"; + +describe("hangboard activity type migration", () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestDatabase(); + }, 120_000); + + afterAll(async () => { + await context?.cleanup(); + }); + + it("applies the migration and exposes hangboard in the Postgres enum", async () => { + const result = await context.db.execute(sql` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'fitness' + AND pg_type.typname = 'canonical_activity_type' + ORDER BY enumsortorder + `); + + const labels = result.map((row) => String(row.enumlabel)); + const climbingIndex = labels.indexOf("climbing"); + + expect(labels).toContain("hangboard"); + expect(labels[climbingIndex + 1]).toBe("hangboard"); + }); +}); diff --git a/src/providers/apple-health/db-insertion.integration.test.ts b/src/providers/apple-health/db-insertion.integration.test.ts index e0b725d33e..0576ece0a2 100644 --- a/src/providers/apple-health/db-insertion.integration.test.ts +++ b/src/providers/apple-health/db-insertion.integration.test.ts @@ -1,5 +1,5 @@ import { resolveProviderActivityType } from "@dofek/training/activity-types"; -import { eq, sql } from "drizzle-orm"; +import { asc, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { drizzleSchema as schema } from "../../db/drizzle-schema.ts"; import { setupTestDatabase, type TestContext } from "../../db/test-helpers.ts"; @@ -17,7 +17,7 @@ import { } from "./db-insertion.ts"; import type { HealthRecord } from "./records.ts"; import type { SleepAnalysisRecord } from "./sleep.ts"; -import { healthRecord } from "./test-helpers.ts"; +import { hangTenWorkout, healthRecord } from "./test-helpers.ts"; import type { HealthWorkout } from "./workouts.ts"; const PROVIDER_ID = "apple_health"; @@ -106,6 +106,184 @@ describe("db-insertion deduplication (integration)", () => { // 2 unique workouts (the two running dupes collapse into 1, plus the cycling) expect(count).toBe(2); }); + + it("preserves an existing ordinary workout name on reimport", async () => { + const start = new Date("2026-08-06T14:00:00Z"); + const workout: HealthWorkout = { + activityType: resolveProviderActivityType("HKWorkoutActivityTypeRunning", "running"), + sourceName: "Apple Watch", + durationSeconds: 1800, + startDate: start, + endDate: new Date("2026-08-06T14:30:00Z"), + }; + + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + await ctx.db + .update(schema.activity) + .set({ name: "Morning Trail Run" }) + .where(eq(schema.activity.externalId, `ah:workout:${start.toISOString()}`)); + + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + + const [storedActivity] = await ctx.db + .select() + .from(schema.activity) + .where(eq(schema.activity.externalId, `ah:workout:${start.toISOString()}`)); + expect(storedActivity?.name).toBe("Morning Trail Run"); + }); + + it("replaces Hang Ten intervals on reimport", async () => { + const start = new Date("2026-08-07T14:00:00Z"); + const workout = hangTenWorkout({ + startDate: start, + endDate: new Date("2026-08-07T14:00:10Z"), + }); + + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + if (!workout.hangTen) throw new Error("Expected Hang Ten metadata"); + workout.hangTen.planName = "Updated Repeaters"; + workout.hangTen.activitySegments = [ + { + stepID: "step-2", + stepNumber: 2, + kind: "work", + holdIDs: [], + durationSeconds: 4, + }, + ]; + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + + const [storedActivity] = await ctx.db + .select() + .from(schema.activity) + .where(eq(schema.activity.externalId, "ah:workout:22222222-2222-4222-8222-222222222222")); + expect(storedActivity).toBeDefined(); + if (!storedActivity) return; + expect(storedActivity.name).toBe("Updated Repeaters"); + + const intervals = await ctx.db + .select() + .from(schema.activityInterval) + .where(eq(schema.activityInterval.activityId, storedActivity.id)) + .orderBy(asc(schema.activityInterval.intervalIndex)); + + expect(intervals).toHaveLength(1); + expect(intervals.map((interval) => interval.label)).toEqual(["Step 2: Work"]); + }); + + it("keeps existing Hang Ten intervals after a malformed reimport", async () => { + const start = new Date("2026-08-07T15:00:00Z"); + const workout = hangTenWorkout({ + startDate: start, + endDate: new Date("2026-08-07T15:00:10Z"), + }); + if (!workout.hangTen) throw new Error("Expected Hang Ten metadata"); + workout.hangTen.sessionId = "44444444-4444-4444-8444-444444444444"; + + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + + workout.hangTen.rawActivitySegments = "{not-json}"; + workout.hangTen.activitySegments = undefined; + workout.hangTen.activitySegmentsError = "Unexpected token n in JSON at position 1"; + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + + const [storedActivity] = await ctx.db + .select() + .from(schema.activity) + .where(eq(schema.activity.externalId, "ah:workout:44444444-4444-4444-8444-444444444444")); + expect(storedActivity).toBeDefined(); + if (!storedActivity) return; + + const intervals = await ctx.db + .select() + .from(schema.activityInterval) + .where(eq(schema.activityInterval.activityId, storedActivity.id)) + .orderBy(asc(schema.activityInterval.intervalIndex)); + + expect(intervals).toHaveLength(2); + expect(intervals.map((interval) => interval.label)).toEqual([ + "Step 1: 19 mm edge", + "Step 1: Rest", + ]); + }); + + it("keeps existing Hang Ten intervals when replacement insertion fails", async () => { + const start = new Date("2026-08-08T14:00:00Z"); + const workout = hangTenWorkout({ + startDate: start, + endDate: new Date("2026-08-08T14:00:07Z"), + hangTen: { + sessionId: "33333333-3333-4333-8333-333333333333", + planName: "Atomic Replacement", + activitySegments: [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: [], + durationSeconds: 7, + }, + ], + }, + }); + + await upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout]); + const hangTen = workout.hangTen; + if (!hangTen) throw new Error("Expected Hang Ten metadata"); + + try { + await ctx.db.execute(sql` + ALTER TABLE fitness.activity_interval + ADD CONSTRAINT activity_interval_replacement_failure_test + CHECK (label <> 'Step 2: Work') NOT VALID + `); + + hangTen.activitySegments = [ + { + stepID: "step-2", + stepNumber: 2, + kind: "work", + holdIDs: [], + durationSeconds: 7, + }, + ]; + hangTen.planName = "Rejected Replacement"; + + await expect(upsertWorkoutBatch(ctx.db, PROVIDER_ID, [workout])).rejects.toThrow(); + + const [storedActivity] = await ctx.db + .select() + .from(schema.activity) + .where(eq(schema.activity.externalId, "ah:workout:33333333-3333-4333-8333-333333333333")); + expect(storedActivity).toBeDefined(); + if (!storedActivity) return; + expect(storedActivity.name).toBe("Atomic Replacement"); + expect(storedActivity.raw).toMatchObject({ + hangTen: { + planName: "Atomic Replacement", + activitySegments: [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + durationSeconds: 7, + }, + ], + }, + }); + + const intervals = await ctx.db + .select() + .from(schema.activityInterval) + .where(eq(schema.activityInterval.activityId, storedActivity.id)); + expect(intervals.map((interval) => interval.label)).toEqual(["Step 1: Work"]); + } finally { + await ctx.db.execute(sql` + ALTER TABLE fitness.activity_interval + DROP CONSTRAINT IF EXISTS activity_interval_replacement_failure_test + `); + } + }); }); describe("insertWithDuplicateDiag — safety net dedup", () => { diff --git a/src/providers/apple-health/db-insertion.test.ts b/src/providers/apple-health/db-insertion.test.ts index 6d80650544..168c8434fd 100644 --- a/src/providers/apple-health/db-insertion.test.ts +++ b/src/providers/apple-health/db-insertion.test.ts @@ -1,4 +1,6 @@ import { resolveProviderActivityType } from "@dofek/training/activity-types"; +import type { SQLWrapper } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it, vi } from "vitest"; import type { SyncDatabase } from "../../db/index.ts"; import { runWithTokenUser } from "../../db/token-user-context.ts"; @@ -99,6 +101,7 @@ vi.mock("../../db/provider-activity-sync.ts", async (importOriginal) => { interface MockInsertCapture { values: Record[][]; + executions: { sql: string; params: unknown[] }[]; partitionKeys: Array; } @@ -106,6 +109,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isSqlWrapper(value: unknown): value is SQLWrapper { + return isRecord(value) && typeof value.getSQL === "function"; +} + function findActivityUpsertValues( predicate: (values: Record) => boolean, ): Record | undefined { @@ -120,14 +127,17 @@ function createMockDb(returningData: Record[] = []): { db: SyncDatabase; capture: MockInsertCapture; } { - const capture: MockInsertCapture = { values: [], partitionKeys: [] }; + const capture: MockInsertCapture = { values: [], executions: [], partitionKeys: [] }; + const dialect = new PgDialect(); metricStreamCapture.current = capture; let returnIndex = 0; providerActivityAbsenceMocks.upsertProviderActivity.mockReset(); providerActivityAbsenceMocks.upsertProviderActivity.mockImplementation(async () => { const template = returningData[returnIndex] ?? - returningData[returningData.length - 1] ?? { id: "10000000-0000-4000-8000-000000000001" }; + returningData[returningData.length - 1] ?? { + id: "10000000-0000-4000-8000-000000000001", + }; returnIndex += 1; if (template.id === undefined) return undefined; return { id: String(template.id) }; @@ -162,7 +172,12 @@ function createMockDb(returningData: Record[] = []): { select: vi.fn().mockReturnValue(selectChain), insert: insertFn, delete: vi.fn().mockReturnValue(deleteChain), - execute: vi.fn().mockResolvedValue([]), + execute: vi.fn((query: SQLWrapper | string) => { + const compiled = + typeof query === "string" ? { sql: query, params: [] } : dialect.sqlToQuery(query.getSQL()); + capture.executions.push({ sql: compiled.sql, params: compiled.params }); + return Promise.resolve([]); + }), }); return { db, capture }; @@ -1442,6 +1457,166 @@ describe("upsertWorkoutBatch", () => { }); }); + it("uses Hang Ten session metadata for hangboard activity rows", async () => { + const start = new Date("2026-08-07T14:00:00Z"); + const { db } = createMockDb([{ id: "10000000-0000-4000-8000-000000000001" }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + startDate: start, + hangTen: { + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + rawActivitySegments: '{"segments":[],"version":1}', + activitySegments: [], + }, + }), + ]); + + expect( + findActivityUpsertValues( + (row) => row.externalId === "ah:workout:11111111-1111-4111-8111-111111111111", + ), + ).toMatchObject({ + providerId: "apple_health", + externalId: "ah:workout:11111111-1111-4111-8111-111111111111", + activityType: { + canonicalType: "hangboard", + providerType: "Hang Ten", + modality: null, + }, + name: "7/3 Repeaters", + sourceName: "Hang Ten", + raw: { + durationSeconds: 1800, + hangTen: { + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + rawActivitySegments: '{"segments":[],"version":1}', + activitySegments: [], + }, + }, + }); + }); + + it("updates the activity name from a reimported Hang Ten plan", async () => { + const { db } = createMockDb([{ id: "10000000-0000-4000-8000-000000000001" }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + hangTen: { planName: "Updated Repeaters" }, + }), + ]); + + const update = providerActivityAbsenceMocks.upsertProviderActivity.mock.calls[0]?.[2]; + const nameExpression = isRecord(update) ? update.name : undefined; + expect(nameExpression).toBeDefined(); + if (!isSqlWrapper(nameExpression)) throw new Error("Expected a SQL name expression"); + + const compiled = new PgDialect().sqlToQuery(nameExpression.getSQL()); + expect(compiled.sql).toContain("excluded.canonical_type = 'hangboard'"); + expect(compiled.sql).toContain("excluded.name"); + }); + + it("preserves a Hang Ten segment parse error without inserting intervals", async () => { + const { db, capture } = createMockDb([{ id: "10000000-0000-4000-8000-000000000001" }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + hangTen: { + planName: "Repeaters", + rawActivitySegments: "{not-json}", + activitySegmentsError: "Unexpected token n in JSON at position 1", + }, + }), + ]); + + expect(capture.executions).toHaveLength(0); + expect(findActivityUpsertValues(() => true)).toMatchObject({ + raw: { + hangTen: { + rawActivitySegments: "{not-json}", + activitySegmentsError: "Unexpected token n in JSON at position 1", + }, + }, + }); + }); + + it("uses returned activity IDs for Hang Ten interval replacement", async () => { + const firstStart = new Date("2026-08-07T14:00:00Z"); + const secondStart = new Date("2026-08-07T15:00:00Z"); + const firstActivityId = "10000000-0000-4000-8000-000000000001"; + const secondActivityId = "10000000-0000-4000-8000-000000000002"; + const { db, capture } = createMockDb([{ id: firstActivityId }, { id: secondActivityId }]); + + await upsertWorkoutBatch(db, "apple_health", [ + makeWorkout({ + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + startDate: firstStart, + hangTen: { + sessionId: "session-first", + planName: "First Plan", + activitySegments: [ + { + stepID: "first-step", + stepNumber: 1, + kind: "work", + holdIDs: [], + holdType: "first-hold", + durationSeconds: 7, + }, + ], + }, + routeLocations: [{ date: firstStart, lat: 11, lng: 12 }], + }), + makeWorkout({ + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + startDate: secondStart, + hangTen: { + sessionId: "session-second", + planName: "Second Plan", + activitySegments: [ + { + stepID: "second-step", + stepNumber: 2, + kind: "work", + holdIDs: [], + holdType: "second-hold", + durationSeconds: 8, + }, + ], + }, + routeLocations: [{ date: secondStart, lat: 21, lng: 22 }], + }), + ]); + + const intervalReplacements = capture.executions.filter((execution) => + execution.sql.includes("activity_interval"), + ); + expect(intervalReplacements).toHaveLength(2); + expect(intervalReplacements[0]?.sql).toMatch( + /INSERT INTO "fitness"\."activity_interval" \(activity_id, interval_index, label, interval_type, started_at, ended_at\)/, + ); + expect(intervalReplacements.map((replacement) => replacement.params)).toEqual( + expect.arrayContaining([ + expect.arrayContaining([firstActivityId, "Step 1: first-hold"]), + expect.arrayContaining([secondActivityId, "Step 2: second-hold"]), + ]), + ); + }); + it("inserts GPS route locations for workouts", async () => { const activityId = "10000000-0000-4000-8000-000000000001"; const { db, capture } = createMockDb([{ id: activityId }]); diff --git a/src/providers/apple-health/db-insertion.ts b/src/providers/apple-health/db-insertion.ts index 52bdea2649..2d7d026645 100644 --- a/src/providers/apple-health/db-insertion.ts +++ b/src/providers/apple-health/db-insertion.ts @@ -1,7 +1,7 @@ import { selectDailyHeartRateVariability } from "@dofek/heart-rate-variability"; import { isIndoorCyclingModality } from "@dofek/training/endurance-types"; import { eq, sql } from "drizzle-orm"; -import type { SyncDatabase } from "../../db/index.ts"; +import type { Database, SyncDatabase } from "../../db/index.ts"; import { type MetricStreamSourceRow, writeMetricStreamBatch, @@ -9,7 +9,7 @@ import { } from "../../db/metric-stream-writer.ts"; import { NUTRIENT_ID_MAP } from "../../db/nutrient-columns.ts"; import { upsertProviderActivity } from "../../db/provider-activity-sync.ts"; -import { dailyMetrics, sleepSession, sleepStage } from "../../db/schema/activity.ts"; +import { activity, dailyMetrics, sleepSession, sleepStage } from "../../db/schema/activity.ts"; import { healthEvent, labResult } from "../../db/schema/clinical.ts"; import { foodEntry, foodEntryNutrient } from "../../db/schema/nutrition.ts"; import { SOURCE_TYPE_FILE } from "../../db/sensor-channels.ts"; @@ -17,9 +17,23 @@ import { getTokenUserId } from "../../db/token-user-context.ts"; import { logger } from "../../logger.ts"; import type { MetricStreamDeleteScopeInput } from "../../metric-stream/events.ts"; import type { MetricStreamEventPublisher } from "../../metric-stream/redpanda-producer.ts"; +import { replaceHangTenIntervals } from "./hang-ten-intervals.ts"; import type { HealthRecord } from "./records.ts"; import type { SleepAnalysisRecord } from "./sleep.ts"; -import type { HealthWorkout } from "./workouts.ts"; +import { type HealthWorkout, workoutExternalId } from "./workouts.ts"; + +type TransactionalSyncDatabase = SyncDatabase & Pick; + +function hasTransaction(db: SyncDatabase): db is TransactionalSyncDatabase { + return "transaction" in db && typeof db.transaction === "function"; +} + +function requireTransactionalDatabase(db: SyncDatabase): TransactionalSyncDatabase { + if (!hasTransaction(db)) { + throw new Error("Apple Health workout upsert requires a transactional database"); + } + return db; +} /** * Deduplicate rows by their conflict key, keeping the last occurrence. @@ -678,46 +692,55 @@ export async function upsertWorkoutBatch( // in a single INSERT statement. const dedupMap = new Map(); for (const w of workouts) { - dedupMap.set(`ah:workout:${w.startDate.toISOString()}`, w); + dedupMap.set(workoutExternalId(w), w); } const uniqueWorkouts = [...dedupMap.values()]; - // Multi-row upsert with RETURNING to get all activity IDs in one statement - const activityResults: { activityId: string; workout: HealthWorkout }[] = []; - - for (let i = 0; i < uniqueWorkouts.length; i += 500) { - const batch = uniqueWorkouts.slice(i, i + 500); - for (const workout of batch) { - const raw: Record = { durationSeconds: workout.durationSeconds }; - if (workout.distanceMeters !== undefined) raw.distanceMeters = workout.distanceMeters; - if (workout.avgHeartRate !== undefined) raw.avgHeartRate = workout.avgHeartRate; - if (workout.maxHeartRate !== undefined) raw.maxHeartRate = workout.maxHeartRate; - - const values = { - providerId, - externalId: `ah:workout:${workout.startDate.toISOString()}`, - activityType: workout.activityType, - startedAt: workout.startDate, - endedAt: workout.endDate, - name: workout.activityType.canonicalType, - sourceName: workout.sourceName, - raw, - }; - - const returned = await upsertProviderActivity(db, values, { - activityType: values.activityType, - startedAt: values.startedAt, - endedAt: values.endedAt, - name: values.name, - sourceName: values.sourceName, - raw: values.raw, - }); + const transactionalDb = requireTransactionalDatabase(db); + // Multi-row upsert with RETURNING to get all activity IDs in one statement. + // Keep activity metadata and Hang Ten intervals in the same transaction so a + // failed replacement cannot leave the activity row ahead of its intervals. + const activityResults = await transactionalDb.transaction(async (transactionDb) => { + const results: { activityId: string; workout: HealthWorkout }[] = []; + + for (let i = 0; i < uniqueWorkouts.length; i += 500) { + const batch = uniqueWorkouts.slice(i, i + 500); + for (const workout of batch) { + const values = { + providerId, + externalId: workoutExternalId(workout), + activityType: workout.activityType, + startedAt: workout.startDate, + endedAt: workout.endDate, + name: workoutName(workout), + sourceName: workout.sourceName, + raw: workoutRawPayload(workout), + }; + + const returned = await upsertProviderActivity(transactionDb, values, { + activityType: values.activityType, + startedAt: values.startedAt, + endedAt: values.endedAt, + name: sql`CASE + WHEN excluded.canonical_type = 'hangboard' AND excluded.source_name = 'Hang Ten' + THEN excluded.name + ELSE ${activity.name} + END`, + sourceName: values.sourceName, + raw: values.raw, + }); - if (returned) { - activityResults.push({ activityId: returned.id, workout }); + if (returned) { + results.push({ activityId: returned.id, workout }); + if (workout.hangTen) { + await replaceHangTenIntervals(transactionDb, returned.id, workout); + } + } } } - } + + return results; + }); // Batch all GPS route locations across all workouts const allGpsRows: MetricStreamSourceRow[] = []; @@ -754,6 +777,19 @@ export async function upsertWorkoutBatch( return activityResults.length; } +function workoutName(workout: HealthWorkout): string { + return workout.hangTen?.planName ?? workout.activityType.canonicalType; +} + +function workoutRawPayload(workout: HealthWorkout): Record { + const raw: Record = { durationSeconds: workout.durationSeconds }; + if (workout.distanceMeters !== undefined) raw.distanceMeters = workout.distanceMeters; + if (workout.avgHeartRate !== undefined) raw.avgHeartRate = workout.avgHeartRate; + if (workout.maxHeartRate !== undefined) raw.maxHeartRate = workout.maxHeartRate; + if (workout.hangTen) raw.hangTen = workout.hangTen; + return raw; +} + export async function upsertSleepBatch( db: SyncDatabase, providerId: string, diff --git a/src/providers/apple-health/hang-ten-intervals.test.ts b/src/providers/apple-health/hang-ten-intervals.test.ts new file mode 100644 index 0000000000..f9ef543c13 --- /dev/null +++ b/src/providers/apple-health/hang-ten-intervals.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { buildHangTenIntervals, hangTenIntervalLabel } from "./hang-ten-intervals.ts"; +import { hangTenActivitySegments, hangTenWorkout } from "./test-helpers.ts"; + +describe("hangTenIntervalLabel", () => { + it("labels work intervals with hold size and type", () => { + expect( + hangTenIntervalLabel({ + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + }), + ).toBe("Step 1: 19 mm edge"); + }); + + it("labels rest intervals by step", () => { + expect( + hangTenIntervalLabel({ + stepID: "step-1-rest", + stepNumber: 1, + kind: "rest", + holdIDs: [], + }), + ).toBe("Step 1: Rest"); + }); +}); + +describe("buildHangTenIntervals", () => { + it("keeps later intervals at the last known time after a missing duration", () => { + const start = new Date("2026-08-07T14:00:00Z"); + const workout = hangTenWorkout({ + startDate: start, + endDate: new Date("2026-08-07T14:01:00Z"), + hangTen: { + planName: "Repeaters", + activitySegments: [ + ...hangTenActivitySegments(), + { + stepID: "step-2", + stepNumber: 2, + kind: "work", + holdIDs: ["jug"], + }, + { + stepID: "step-2-rest", + stepNumber: 2, + kind: "rest", + holdIDs: [], + durationSeconds: 5, + }, + ], + }, + }); + + expect(buildHangTenIntervals("act-1", workout)).toEqual([ + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 0, + label: "Step 1: 19 mm edge", + intervalType: "work", + startedAt: start, + endedAt: new Date("2026-08-07T14:00:07Z"), + }), + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 1, + label: "Step 1: Rest", + intervalType: "rest", + startedAt: new Date("2026-08-07T14:00:07Z"), + endedAt: new Date("2026-08-07T14:00:10Z"), + }), + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 2, + label: "Step 2: Work", + intervalType: "work", + startedAt: new Date("2026-08-07T14:00:10Z"), + endedAt: undefined, + }), + expect.objectContaining({ + activityId: "act-1", + intervalIndex: 3, + label: "Step 2: Rest", + intervalType: "rest", + startedAt: new Date("2026-08-07T14:00:10Z"), + endedAt: undefined, + }), + ]); + }); +}); diff --git a/src/providers/apple-health/hang-ten-intervals.ts b/src/providers/apple-health/hang-ten-intervals.ts new file mode 100644 index 0000000000..d42ea3a290 --- /dev/null +++ b/src/providers/apple-health/hang-ten-intervals.ts @@ -0,0 +1,84 @@ +import { eq, sql } from "drizzle-orm"; +import type { SyncDatabase } from "../../db/index.ts"; +import { activityInterval } from "../../db/schema/activity.ts"; +import type { HangTenActivitySegment, HealthWorkout } from "./workouts.ts"; + +export function hangTenIntervalLabel(segment: HangTenActivitySegment): string { + if (segment.kind === "rest") return `Step ${segment.stepNumber}: Rest`; + if (segment.sizeMillimeters !== undefined && segment.holdType) { + return `Step ${segment.stepNumber}: ${segment.sizeMillimeters} mm ${segment.holdType}`; + } + if (segment.holdType) return `Step ${segment.stepNumber}: ${segment.holdType}`; + return `Step ${segment.stepNumber}: Work`; +} + +export function buildHangTenIntervals( + activityId: string, + workout: HealthWorkout, +): (typeof activityInterval.$inferInsert)[] { + const segments = workout.hangTen?.activitySegments; + if (!segments || segments.length === 0) return []; + + const rows: (typeof activityInterval.$inferInsert)[] = []; + let cursor = workout.startDate; + let offsetsAreUnambiguous = true; + for (const [index, segment] of segments.entries()) { + const startedAt = cursor; + const endedAt: Date | undefined = + offsetsAreUnambiguous && segment.durationSeconds !== undefined + ? new Date(cursor.getTime() + segment.durationSeconds * 1000) + : undefined; + rows.push({ + activityId, + intervalIndex: index, + label: hangTenIntervalLabel(segment), + intervalType: segment.kind, + startedAt, + endedAt, + }); + if (endedAt) { + cursor = endedAt; + } else { + offsetsAreUnambiguous = false; + } + } + return rows; +} + +export async function replaceHangTenIntervals( + db: SyncDatabase, + activityId: string, + workout: HealthWorkout, +): Promise { + const segments = workout.hangTen?.activitySegments; + if (segments === undefined) return; + + const intervals = buildHangTenIntervals(activityId, workout); + if (intervals.length === 0) { + await db.delete(activityInterval).where(eq(activityInterval.activityId, activityId)); + return; + } + + const replacementValues = sql.join( + intervals.map( + (interval) => sql`( + ${interval.activityId}::uuid, + ${interval.intervalIndex}, + ${interval.label ?? null}, + ${interval.intervalType ?? null}, + ${interval.startedAt}, + ${interval.endedAt ?? null} + )`, + ), + sql`, `, + ); + + await db.execute(sql` + WITH deleted AS ( + DELETE FROM ${activityInterval} + WHERE ${activityInterval.activityId} = ${activityId} + ) + INSERT INTO ${activityInterval} (activity_id, interval_index, label, interval_type, started_at, ended_at) + VALUES ${replacementValues} + `); +} diff --git a/src/providers/apple-health/import.integration.test.ts b/src/providers/apple-health/import.integration.test.ts index 8699f9593b..bd1de3576c 100644 --- a/src/providers/apple-health/import.integration.test.ts +++ b/src/providers/apple-health/import.integration.test.ts @@ -2,7 +2,7 @@ import { execSync } from "node:child_process"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sql } from "drizzle-orm"; +import { asc, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { drizzleSchema as schema } from "../../db/drizzle-schema.ts"; import { setupTestDatabase, type TestContext } from "../../db/test-helpers.ts"; @@ -728,6 +728,19 @@ const IMPORT_XML = ` + + + + + + + + + { ).toBe(true); }); + it("imports Hang Ten workouts with their activity intervals", async () => { + const activities = await ctx.db.select().from(schema.activity); + const hangboard = activities.find( + (activityRow) => activityRow.externalId === "ah:workout:11111111-1111-4111-8111-111111111111", + ); + const intervals = hangboard + ? await ctx.db + .select() + .from(schema.activityInterval) + .where(eq(schema.activityInterval.activityId, hangboard.id)) + .orderBy(asc(schema.activityInterval.intervalIndex)) + : []; + + expect(hangboard?.canonicalType).toBe("hangboard"); + expect(hangboard?.name).toBe("7/3 Repeaters"); + expect(hangboard?.sourceName).toBe("Hang Ten"); + expect(hangboard?.externalId).toBe("ah:workout:11111111-1111-4111-8111-111111111111"); + expect(hangboard?.raw).toMatchObject({ + hangTen: { + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + rawActivitySegments: expect.stringContaining('"stepID":"step-1"'), + activitySegments: [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, + { + stepID: "step-1-rest", + stepNumber: 1, + kind: "rest", + holdIDs: [], + durationSeconds: 3, + }, + ], + }, + }); + expect(intervals.map((interval) => interval.label)).toEqual([ + "Step 1: 19 mm edge", + "Step 1: Rest", + ]); + }); + it("creates health_event rows for category records (mindful session)", async () => { const rows = await ctx.db.select().from(schema.healthEvent); const mindful = rows.find((r) => r.type === "HKCategoryTypeIdentifierMindfulSession"); diff --git a/src/providers/apple-health/import.test.ts b/src/providers/apple-health/import.test.ts index 6380da9968..44a952bca7 100644 --- a/src/providers/apple-health/import.test.ts +++ b/src/providers/apple-health/import.test.ts @@ -1499,6 +1499,74 @@ describe("runImport (control-flow mutation killers)", () => { expect(upsertHealthEventBatch).not.toHaveBeenCalled(); }); + it("reports malformed Hang Ten segments after importing the workout", async () => { + vi.resetModules(); + const upsertWorkoutBatch = vi.fn().mockResolvedValue(1); + + vi.doMock("./db-insertion.ts", () => ({ + METRIC_STREAM_TYPES: {}, + BODY_MEASUREMENT_TYPES: new Set(), + DAILY_METRIC_TYPES: new Set(), + NUTRITION_TYPES: {}, + ALL_ROUTED_TYPES: new Set(), + upsertMetricStreamBatch: vi.fn().mockResolvedValue(0), + upsertBodyMeasurementBatch: vi.fn().mockResolvedValue(0), + upsertDailyMetricsBatch: vi.fn().mockResolvedValue(0), + upsertNutritionBatch: vi.fn().mockResolvedValue(0), + upsertHealthEventBatch: vi.fn().mockResolvedValue(0), + upsertSleepBatch: vi.fn().mockResolvedValue(0), + upsertWorkoutBatch, + aggregateSpO2ToDailyMetrics: vi.fn().mockResolvedValue(undefined), + aggregateSkinTempToDailyMetrics: vi.fn().mockResolvedValue(undefined), + })); + + vi.doMock("./streaming.ts", () => ({ + streamHealthExport: vi.fn( + async ( + _xmlPath: string, + _since: Date, + handlers: { + onWorkoutBatch: (workouts: Array>) => Promise; + }, + ) => { + await handlers.onWorkoutBatch([ + { + activityType: "hangboard", + sourceName: "Hang Ten", + durationSeconds: 600, + startDate: new Date("2026-08-07T14:00:00Z"), + endDate: new Date("2026-08-07T14:10:00Z"), + hangTen: { + planName: "Max Hangs", + rawActivitySegments: "{not json", + activitySegmentsError: + "Invalid Hang Ten activity segments JSON: could not parse JSON", + }, + }, + ]); + return { recordCount: 0, workoutCount: 1, sleepCount: 0, categoryCount: 0 }; + }, + ), + })); + + const { runImport: mockedRunImport } = await import("./import.ts"); + const result = await mockedRunImport( + createRunImportDbForMockedStreaming(), + "apple_health", + "/tmp/stream.xml", + new Date("2026-08-07T00:00:00Z"), + ); + + expect(result.errors).toEqual([ + expect.objectContaining({ + externalId: "ah:workout:2026-08-07T14:00:00.000Z", + message: "Invalid Hang Ten activity segments JSON: could not parse JSON", + }), + ]); + expect(result.recordsSynced).toBe(1); + expect(upsertWorkoutBatch).toHaveBeenCalledTimes(1); + }); + it("does not run daily metric aggregation when streamed records contain no metric records", async () => { vi.resetModules(); diff --git a/src/providers/apple-health/import.ts b/src/providers/apple-health/import.ts index 24bf80cd01..7beab47f53 100644 --- a/src/providers/apple-health/import.ts +++ b/src/providers/apple-health/import.ts @@ -58,6 +58,7 @@ import { import type { HealthRecord } from "./records.ts"; import type { ProgressInfo } from "./streaming.ts"; import { streamHealthExport } from "./streaming.ts"; +import { type HealthWorkout, workoutExternalId } from "./workouts.ts"; const appleMedicationDoseEventSchema = z .object({ @@ -212,6 +213,19 @@ export function defaultConsoleProgress(info: ProgressInfo): void { } } +function collectWorkoutImportErrors(workouts: HealthWorkout[]): SyncError[] { + return workouts.flatMap((workout) => { + const message = workout.hangTen?.activitySegmentsError; + if (!message) return []; + return [ + { + message, + externalId: workoutExternalId(workout), + }, + ]; + }); +} + // ============================================================ // Import logic (shared between CLI and sync) // ============================================================ @@ -321,7 +335,7 @@ export async function runImport( }, onWorkoutBatch: async (workouts) => { for (const workout of workouts) { - presentWorkoutExternalIds.add(`ah:workout:${workout.startDate.toISOString()}`); + presentWorkoutExternalIds.add(workoutExternalId(workout)); const workoutEnd = workout.endDate ?? workout.startDate; if (!latestWorkoutTimestamp || workoutEnd > latestWorkoutTimestamp) { latestWorkoutTimestamp = workoutEnd; @@ -335,6 +349,7 @@ export async function runImport( metricStreamPublisher, ); recordsSynced += workoutCount; + errors.push(...collectWorkoutImportErrors(workouts)); }, onCategoryBatch: async (records) => { // Insert category records into health_event table diff --git a/src/providers/apple-health/parsing-extra.test.ts b/src/providers/apple-health/parsing-extra.test.ts new file mode 100644 index 0000000000..ce68b07c62 --- /dev/null +++ b/src/providers/apple-health/parsing-extra.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkout } from "./workouts.ts"; + +describe("parseWorkout — Hang Ten validation", () => { + it("reports malformed Hang Ten activity segment JSON", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + duration: "10", + durationUnit: "min", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": "{not json", + }, + ); + + expect(result.activityType.canonicalType).toBe("hangboard"); + expect(result.hangTen?.rawActivitySegments).toBe("{not json"); + expect(result.hangTen?.activitySegments).toBeUndefined(); + expect(result.hangTen?.activitySegmentsError).toContain( + "Invalid Hang Ten activity segments JSON", + ); + }); + + it("requires the exact Hang Ten brand metadata value", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: " Hang Ten ", + "HangTen.PlanName": "Max Hangs", + }, + ); + + expect(result.activityType.canonicalType).toBe("strength"); + expect(result.hangTen).toBeUndefined(); + }); + + it("reports structurally invalid Hang Ten activity segment JSON", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": JSON.stringify({ + segments: [{ stepID: "step-1" }], + }), + }, + ); + + expect(result.hangTen?.activitySegments).toBeUndefined(); + expect(result.hangTen?.activitySegmentsError).toBe( + "Invalid Hang Ten activity segments JSON: segment metadata has invalid fields", + ); + }); +}); diff --git a/src/providers/apple-health/parsing.test.ts b/src/providers/apple-health/parsing.test.ts index 596e7ab40a..70bce8bb32 100644 --- a/src/providers/apple-health/parsing.test.ts +++ b/src/providers/apple-health/parsing.test.ts @@ -8,6 +8,7 @@ import { parseWorkout, parseWorkoutStatistics, type WorkoutStatistics, + workoutExternalId, } from "./workouts.ts"; // ============================================================ @@ -323,6 +324,220 @@ describe("Apple Health Provider -- parsing", () => { }); describe("parseWorkout", () => { + it("parses Hang Ten workout metadata", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + duration: "10", + durationUnit: "min", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "7/3 Repeaters", + "HangTen.SessionID": "11111111-1111-4111-8111-111111111111", + "HangTen.BoardID": "metolius-compact-ii", + "HangTen.BoardName": "Metolius Compact II", + "HangTen.ActivitySegments": JSON.stringify({ + version: 1, + segments: [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, + ], + }), + }, + ); + + expect(result.activityType).toEqual({ + canonicalType: "hangboard", + providerType: "Hang Ten", + modality: null, + }); + expect(result.sourceName).toBe("Hang Ten"); + expect(result.hangTen).toMatchObject({ + sessionId: "11111111-1111-4111-8111-111111111111", + planName: "7/3 Repeaters", + boardId: "metolius-compact-ii", + boardName: "Metolius Compact II", + }); + expect(result.hangTen?.activitySegments).toEqual([ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, + ]); + }); + + it("ignores Hang Ten metadata for non-functional-strength workouts", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeRunning", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + }, + ); + + expect(result.activityType.canonicalType).toBe("running"); + expect(result.hangTen).toBeUndefined(); + }); + + it.each([undefined, "", " "])("ignores Hang Ten metadata when PlanName is %s", (planName) => { + const metadata: Record = { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + }; + if (planName !== undefined) metadata["HangTen.PlanName"] = planName; + + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + metadata, + ); + + expect(result.activityType).toEqual({ + canonicalType: "strength", + providerType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + modality: "functional", + }); + expect(result.hangTen).toBeUndefined(); + }); + + it("reports malformed Hang Ten activity segment JSON", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + duration: "10", + durationUnit: "min", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": "{not json", + }, + ); + + expect(result.activityType.canonicalType).toBe("hangboard"); + expect(result.hangTen?.rawActivitySegments).toBe("{not json"); + expect(result.hangTen?.activitySegments).toBeUndefined(); + expect(result.hangTen?.activitySegmentsError).toContain( + "Invalid Hang Ten activity segments JSON", + ); + }); + + it("accepts an empty Hang Ten activity segment array", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": JSON.stringify({ version: 1, segments: [] }), + }, + ); + + expect(result.hangTen?.activitySegments).toEqual([]); + expect(result.hangTen?.activitySegmentsError).toBeUndefined(); + }); + + it("reports empty Hang Ten activity segment JSON", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": "", + }, + ); + + expect(result.hangTen?.rawActivitySegments).toBe(""); + expect(result.hangTen?.activitySegments).toBeUndefined(); + expect(result.hangTen?.activitySegmentsError).toContain( + "Invalid Hang Ten activity segments JSON", + ); + }); + + it("reports structurally invalid Hang Ten activity segment JSON", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.ActivitySegments": '{"segments":[{"stepID":"step-1"}]}', + }, + ); + + expect(result.hangTen?.activitySegments).toBeUndefined(); + expect(result.hangTen?.activitySegmentsError).toBe( + "Invalid Hang Ten activity segments JSON: segment metadata has invalid fields", + ); + }); + + it("requires the exact Hang Ten brand metadata value", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: " Hang Ten ", + "HangTen.PlanName": "Max Hangs", + }, + ); + + expect(result.activityType.canonicalType).toBe("strength"); + expect(result.hangTen).toBeUndefined(); + }); + + it("uses the Hang Ten session ID for the workout external ID", () => { + const result = parseWorkout( + { + workoutActivityType: "HKWorkoutActivityTypeFunctionalStrengthTraining", + startDate: "2026-08-07 07:00:00 -0700", + endDate: "2026-08-07 07:10:00 -0700", + }, + { + HKMetadataKeyWorkoutBrandName: "Hang Ten", + "HangTen.PlanName": "Max Hangs", + "HangTen.SessionID": "11111111-1111-4111-8111-111111111111", + }, + ); + + expect(workoutExternalId(result)).toBe("ah:workout:11111111-1111-4111-8111-111111111111"); + }); + it("parses workout attributes", () => { const result = parseWorkout(workoutAttrs); expect(result.activityType.canonicalType).toBe("running"); diff --git a/src/providers/apple-health/streaming.test.ts b/src/providers/apple-health/streaming.test.ts index dfbd91975f..4b2ad4c9d9 100644 --- a/src/providers/apple-health/streaming.test.ts +++ b/src/providers/apple-health/streaming.test.ts @@ -258,6 +258,95 @@ describe("streamHealthExport — sleep filtering", () => { // ============================================================ describe("streamHealthExport — workout edge cases", () => { + it("attaches MetadataEntry values to workouts", async () => { + const xml = ` + + + + + + +`; + const path = writeXml("hang-ten-workout.xml", xml); + + const workouts: HealthWorkout[] = []; + await streamHealthExport(path, new Date("2020-01-01"), { + onRecordBatch: async () => {}, + onSleepBatch: async () => {}, + onWorkoutBatch: async (batch) => { + workouts.push(...batch); + }, + }); + + expect(workouts[0]?.activityType.canonicalType).toBe("hangboard"); + expect(workouts[0]?.hangTen?.planName).toBe("7/3 Repeaters"); + expect(workouts[0]?.metadata?.["HangTen.PlanName"]).toBe("7/3 Repeaters"); + }); + + it("does not treat metadata-shaped workout children as MetadataEntry values", async () => { + const xml = ` + + + + + +`; + const path = writeXml("metadata-shaped-workout-child.xml", xml); + + const workouts: HealthWorkout[] = []; + await streamHealthExport(path, new Date("2020-01-01"), { + onRecordBatch: async () => {}, + onSleepBatch: async () => {}, + onWorkoutBatch: async (batch) => { + workouts.push(...batch); + }, + }); + + expect(workouts).toHaveLength(1); + expect(workouts[0]?.activityType.canonicalType).toBe("strength"); + expect(workouts[0]?.activityType.modality).toBe("functional"); + expect(workouts[0]?.hangTen).toBeUndefined(); + expect(workouts[0]?.avgHeartRate).toBe(122); + expect(workouts[0]?.metadata?.["HangTen.PlanName"]).toBeUndefined(); + }); + + it("ignores MetadataEntry nodes without values", async () => { + const xml = ` + + + + + +`; + const path = writeXml("metadata-entry-without-value.xml", xml); + + const workouts: HealthWorkout[] = []; + await streamHealthExport(path, new Date("2020-01-01"), { + onRecordBatch: async () => {}, + onSleepBatch: async () => {}, + onWorkoutBatch: async (batch) => { + workouts.push(...batch); + }, + }); + + expect(workouts).toHaveLength(1); + expect(workouts[0]?.hangTen).toBeUndefined(); + expect(Object.hasOwn(workouts[0]?.metadata ?? {}, "HangTen.PlanName")).toBe(false); + }); + it("handles workout with route locations but no WorkoutStatistics", async () => { const xml = ` diff --git a/src/providers/apple-health/streaming.ts b/src/providers/apple-health/streaming.ts index 3ae0b4348b..c2eff2d91b 100644 --- a/src/providers/apple-health/streaming.ts +++ b/src/providers/apple-health/streaming.ts @@ -11,6 +11,7 @@ import { } from "./records.ts"; import { parseSleepAnalysis, type SleepAnalysisRecord } from "./sleep.ts"; import { + applyWorkoutMetadata, enrichWorkoutFromStats, type HealthWorkout, parseWorkout, @@ -88,6 +89,7 @@ export function streamHealthExport( // State for nested elements let currentWorkout: HealthWorkout | null = null; + let currentWorkoutMetadata: Record = {}; let currentWorkoutStats: WorkoutStatistics[] = []; let currentRouteLocations: RouteLocation[] = []; let insideWorkoutRoute = false; @@ -160,6 +162,7 @@ export function streamHealthExport( function flushWorkout() { if (currentWorkout) { + currentWorkout = applyWorkoutMetadata(currentWorkout, currentWorkoutMetadata); if (currentWorkoutStats.length > 0) { enrichWorkoutFromStats(currentWorkout, currentWorkoutStats); } @@ -172,6 +175,7 @@ export function streamHealthExport( } } currentWorkout = null; + currentWorkoutMetadata = {}; currentWorkoutStats = []; } @@ -193,11 +197,19 @@ export function streamHealthExport( if (record && record.startDate >= since) addRecord(record); } } else if (node.name === "Workout") { + currentWorkoutMetadata = {}; const workout = parseWorkout(attrs); if (workout.startDate >= since) { currentWorkout = workout; currentWorkoutStats = []; } + } else if ( + node.name === "MetadataEntry" && + currentWorkout && + attrs.key && + attrs.value !== undefined + ) { + currentWorkoutMetadata[attrs.key] = attrs.value; } else if (node.name === "WorkoutStatistics" && currentWorkout) { const stat = parseWorkoutStatistics(attrs); if (stat) currentWorkoutStats.push(stat); diff --git a/src/providers/apple-health/test-helpers.ts b/src/providers/apple-health/test-helpers.ts index 389af67b44..5fe9874896 100644 --- a/src/providers/apple-health/test-helpers.ts +++ b/src/providers/apple-health/test-helpers.ts @@ -5,6 +5,7 @@ import type { } from "../../db/provider-data-deletion.ts"; import type { Database } from "../../db/typed-sql.ts"; import type { HealthRecord } from "./records.ts"; +import type { HangTenActivitySegment, HealthWorkout } from "./workouts.ts"; export async function resolveProviderDataGenerationsForTest( database: Database, @@ -35,3 +36,45 @@ export function healthRecord( creationDate: startDate, }; } + +export function hangTenActivitySegments(): HangTenActivitySegment[] { + return [ + { + stepID: "step-1", + stepNumber: 1, + kind: "work", + holdIDs: ["edge-19"], + holdType: "edge", + sizeMillimeters: 19, + durationSeconds: 7, + }, + { + stepID: "step-1-rest", + stepNumber: 1, + kind: "rest", + holdIDs: [], + durationSeconds: 3, + }, + ]; +} + +export function hangTenWorkout(overrides: Partial = {}): HealthWorkout { + const startDate = overrides.startDate ?? new Date("2026-08-07T14:00:00Z"); + return { + activityType: { + canonicalType: "hangboard", + providerType: "Hang Ten", + modality: null, + }, + sourceName: "Hang Ten", + durationSeconds: 10, + startDate, + endDate: overrides.endDate ?? new Date(startDate.getTime() + 10_000), + hangTen: { + sessionId: "22222222-2222-4222-8222-222222222222", + planName: "Repeaters", + activitySegments: hangTenActivitySegments(), + }, + ...overrides, + }; +} diff --git a/src/providers/apple-health/workouts.ts b/src/providers/apple-health/workouts.ts index 226cd2a923..f50aa1fa70 100644 --- a/src/providers/apple-health/workouts.ts +++ b/src/providers/apple-health/workouts.ts @@ -3,9 +3,30 @@ import { resolveProviderActivityType, } from "@dofek/training/activity-types"; import { APPLE_HEALTH_WORKOUT_TYPE_MAP } from "@dofek/training/training"; +import { z } from "zod"; import { parseHealthDate } from "./dates.ts"; import type { RouteLocation } from "./records.ts"; +export interface HangTenActivitySegment { + stepID: string; + stepNumber: number; + kind: "work" | "rest"; + holdIDs: string[]; + holdType?: string; + sizeMillimeters?: number; + durationSeconds?: number; +} + +export interface HangTenWorkoutMetadata { + sessionId?: string; + planName: string; + boardId?: string; + boardName?: string; + rawActivitySegments?: string; + activitySegments?: HangTenActivitySegment[]; + activitySegmentsError?: string; +} + export interface HealthWorkout { activityType: ProviderActivityType; sourceName: string | null; @@ -16,6 +37,14 @@ export interface HealthWorkout { startDate: Date; endDate: Date; routeLocations?: RouteLocation[]; + metadata?: Record; + hangTen?: HangTenWorkoutMetadata; +} + +export function workoutExternalId(workout: HealthWorkout): string { + return workout.hangTen?.sessionId + ? `ah:workout:${workout.hangTen.sessionId}` + : `ah:workout:${workout.startDate.toISOString()}`; } // Re-export as WORKOUT_TYPE_MAP for backward compatibility @@ -45,7 +74,10 @@ export function normalizeDistance(value: string, unit: string): number { } } -export function parseWorkout(attrs: Record): HealthWorkout { +export function parseWorkout( + attrs: Record, + metadata: Record = {}, +): HealthWorkout { const rawType = attrs.workoutActivityType ?? "HKWorkoutActivityTypeOther"; const activityType = resolveProviderActivityType(rawType, WORKOUT_TYPE_MAP[rawType] ?? "other"); @@ -56,13 +88,99 @@ export function parseWorkout(attrs: Record): HealthWorkout { distanceMeters = normalizeDistance(attrs.totalDistance, attrs.totalDistanceUnit ?? "m"); } + return applyWorkoutMetadata( + { + activityType, + sourceName: attrs.sourceName ?? null, + durationSeconds, + distanceMeters, + startDate: parseHealthDate(attrs.startDate ?? ""), + endDate: parseHealthDate(attrs.endDate ?? ""), + }, + metadata, + ); +} + +function trimmedMetadataValue(metadata: Record, key: string): string | undefined { + const value = metadata[key]?.trim(); + return value ? value : undefined; +} + +const hangTenActivityMetadataSchema = z.object({ + version: z.number().optional(), + segments: z.array( + z.object({ + stepID: z.string(), + stepNumber: z.number(), + kind: z.enum(["work", "rest"]), + holdIDs: z.array(z.string()), + holdType: z.string().optional(), + sizeMillimeters: z.number().optional(), + durationSeconds: z.number().optional(), + }), + ), +}); + +function parseHangTenActivitySegments(raw: string): { + segments?: HangTenActivitySegment[]; + error?: string; +} { + try { + const parsed: unknown = JSON.parse(raw); + const result = hangTenActivityMetadataSchema.safeParse(parsed); + if (!result.success) { + return { + error: "Invalid Hang Ten activity segments JSON: segment metadata has invalid fields", + }; + } + return { segments: result.data.segments }; + } catch { + return { error: "Invalid Hang Ten activity segments JSON: could not parse JSON" }; + } +} + +function hangTenWorkoutOverrides( + activityType: ProviderActivityType, + metadata: Record, +): Partial { + if ( + activityType.canonicalType !== "strength" || + activityType.modality !== "functional" || + metadata.HKMetadataKeyWorkoutBrandName !== "Hang Ten" + ) { + return {}; + } + + const planName = trimmedMetadataValue(metadata, "HangTen.PlanName"); + if (!planName) return {}; + + const rawActivitySegments = metadata["HangTen.ActivitySegments"]; + const parsedActivitySegments = + rawActivitySegments !== undefined ? parseHangTenActivitySegments(rawActivitySegments) : {}; + + return { + activityType: resolveProviderActivityType("Hang Ten", "hangboard"), + sourceName: "Hang Ten", + hangTen: { + sessionId: trimmedMetadataValue(metadata, "HangTen.SessionID"), + planName, + boardId: trimmedMetadataValue(metadata, "HangTen.BoardID"), + boardName: trimmedMetadataValue(metadata, "HangTen.BoardName"), + rawActivitySegments, + activitySegments: parsedActivitySegments.segments, + activitySegmentsError: parsedActivitySegments.error, + }, + }; +} + +export function applyWorkoutMetadata( + workout: HealthWorkout, + metadata: Record, +): HealthWorkout { return { - activityType, - sourceName: attrs.sourceName ?? null, - durationSeconds, - distanceMeters, - startDate: parseHealthDate(attrs.startDate ?? ""), - endDate: parseHealthDate(attrs.endDate ?? ""), + ...workout, + metadata, + ...hangTenWorkoutOverrides(workout.activityType, metadata), }; } diff --git a/vendor/image-size/LICENSE b/vendor/image-size/LICENSE new file mode 100644 index 0000000000..8bdffcff7d --- /dev/null +++ b/vendor/image-size/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2013-Present Aditya Yadav, http://netroy.in + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/image-size/Readme.md b/vendor/image-size/Readme.md new file mode 100644 index 0000000000..616dbba10c --- /dev/null +++ b/vendor/image-size/Readme.md @@ -0,0 +1,170 @@ +# image-size + +[![Build Status](https://circleci.com/gh/image-size/image-size.svg?style=shield)](https://circleci.com/gh/image-size/image-size) +[![Package Version](https://img.shields.io/npm/v/image-size.svg)](https://www.npmjs.com/package/image-size) +[![Downloads](https://img.shields.io/npm/dm/image-size.svg)](http://npm-stat.com/charts.html?package=image-size&author=&from=&to=) + +A [Node](https://nodejs.org/en/) module to get dimensions of any image file + +## Supported formats + +- BMP +- CUR +- DDS +- GIF +- HEIC (HEIF, AVCI, AVIF) +- ICNS +- ICO +- J2C +- JPEG-2000 (JP2) +- JPEG +- JPEG-XL +- KTX (1 and 2) +- PNG +- PNM (PAM, PBM, PFM, PGM, PPM) +- PSD +- SVG +- TGA +- TIFF +- WebP + +## Programmatic Usage + +```shell +pnpm add image-size +``` + +### Synchronous + +```javascript +const sizeOf = require("image-size") +const dimensions = sizeOf("images/funny-cats.png") +console.log(dimensions.width, dimensions.height) +``` + +### Asynchronous + +```javascript +const sizeOf = require("image-size") +sizeOf("images/funny-cats.png", function (err, dimensions) { + console.log(dimensions.width, dimensions.height) +}) +``` + +NOTE: The asynchronous version doesn't work if the input is a Buffer. Use synchronous version instead. + +Also, the asynchronous functions have a default concurrency limit of **100** +To change this limit, you can call the `setConcurrency` function like this: + +```javascript +const sizeOf = require("image-size") +sizeOf.setConcurrency(123456) +``` + +### Using promises (nodejs 10.x+) + +```javascript +const { promisify } = require("util") +const sizeOf = promisify(require("image-size")) +sizeOf("images/funny-cats.png") + .then((dimensions) => { + console.log(dimensions.width, dimensions.height) + }) + .catch((err) => console.error(err)) +``` + +### Async/Await (Typescript & ES7) + +```javascript +const { promisify } = require("util") +const sizeOf = promisify(require("image-size"))(async () => { + try { + const dimensions = await sizeOf("images/funny-cats.png") + console.log(dimensions.width, dimensions.height) + } catch (err) { + console.error(err) + } +})().then((c) => console.log(c)) +``` + +### Multi-size + +If the target file is an icon (.ico) or a cursor (.cur), the `width` and `height` will be the ones of the first found image. + +An additional `images` array is available and returns the dimensions of all the available images + +```javascript +const sizeOf = require("image-size") +const images = sizeOf("images/multi-size.ico").images +for (const dimensions of images) { + console.log(dimensions.width, dimensions.height) +} +``` + +### Using a URL + +```javascript +const url = require("url") +const http = require("http") + +const sizeOf = require("image-size") + +const imgUrl = "http://my-amazing-website.com/image.jpeg" +const options = url.parse(imgUrl) + +http.get(options, function (response) { + const chunks = [] + response + .on("data", function (chunk) { + chunks.push(chunk) + }) + .on("end", function () { + const buffer = Buffer.concat(chunks) + console.log(sizeOf(buffer)) + }) +}) +``` + +You can optionally check the buffer lengths & stop downloading the image after a few kilobytes. +**You don't need to download the entire image** + +### Disabling certain image types + +```javascript +const imageSize = require("image-size") +imageSize.disableTypes(["tiff", "ico"]) +``` + +### Disabling all file-system reads + +```javascript +const imageSize = require("image-size") +imageSize.disableFS(true) +``` + +### JPEG image orientation + +If the orientation is present in the JPEG EXIF metadata, it will be returned by the function. The orientation value is a [number between 1 and 8](https://exiftool.org/TagNames/EXIF.html#:~:text=0x0112,8%20=%20Rotate%20270%20CW) representing a type of orientation. + +```javascript +const sizeOf = require("image-size") +const dimensions = sizeOf("images/photo.jpeg") +console.log(dimensions.orientation) +``` + +## Command-Line Usage (CLI) + +```shell +pnpm add --global image-size +``` + +followed by + +```shell +image-size image1 [image2] [image3] ... +``` + +## Credits + +not a direct port, but an attempt to have something like +[dabble's imagesize](https://github.com/dabble/imagesize/blob/master/lib/image_size.rb) as a node module. diff --git a/vendor/image-size/bin/image-size.js b/vendor/image-size/bin/image-size.js new file mode 100755 index 0000000000..e6ed129e41 --- /dev/null +++ b/vendor/image-size/bin/image-size.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-var-requires */ +'use strict' + +const fs = require('fs') +const path = require('path') +const { imageSize } = require('..') + +const files = process.argv.slice(2) + +if (!files.length) { + console.error('Usage: image-size image1 [image2] [image3] ...') + process.exit(-1) +} + +const red = ['\x1B[31m', '\x1B[39m'] +const grey = ['\x1B[90m', '\x1B[39m'] +const green = ['\x1B[32m', '\x1B[39m'] + +function colorize(text, color) { + return color[0] + text + color[1] +} + +files.forEach(function (image) { + try { + if (fs.existsSync(path.resolve(image))) { + const greyX = colorize('x', grey) + const greyImage = colorize(image, grey) + const size = imageSize(image) + const sizes = size.images || [size] + sizes.forEach((size) => { + let greyType = '' + if (size.type) { + greyType = colorize(' (' + size.type + ')', grey) + } + console.info( + colorize(size.width, green) + + greyX + + colorize(size.height, green) + + ' - ' + + greyImage + + greyType, + ) + }) + } else { + console.error("file doesn't exist - ", image) + } + } catch (e) { + console.error(colorize(e.message, red), '-', image) + } +}) diff --git a/vendor/image-size/dist/detector.d.ts b/vendor/image-size/dist/detector.d.ts new file mode 100644 index 0000000000..d16665dba8 --- /dev/null +++ b/vendor/image-size/dist/detector.d.ts @@ -0,0 +1,2 @@ +import type { imageType } from './types/index'; +export declare function detector(input: Uint8Array): imageType | undefined; diff --git a/vendor/image-size/dist/detector.js b/vendor/image-size/dist/detector.js new file mode 100644 index 0000000000..6b9ef6f78b --- /dev/null +++ b/vendor/image-size/dist/detector.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.detector = void 0; +const index_1 = require("./types/index"); +const keys = Object.keys(index_1.typeHandlers); +// This map helps avoid validating for every single image type +const firstBytes = { + 0x38: 'psd', + 0x42: 'bmp', + 0x44: 'dds', + 0x47: 'gif', + 0x49: 'tiff', + 0x4d: 'tiff', + 0x52: 'webp', + 0x69: 'icns', + 0x89: 'png', + 0xff: 'jpg', +}; +function detector(input) { + const byte = input[0]; + if (byte in firstBytes) { + const type = firstBytes[byte]; + if (type && index_1.typeHandlers[type].validate(input)) { + return type; + } + } + const finder = (key) => index_1.typeHandlers[key].validate(input); + return keys.find(finder); +} +exports.detector = detector; diff --git a/vendor/image-size/dist/index.d.ts b/vendor/image-size/dist/index.d.ts new file mode 100644 index 0000000000..091ae255c8 --- /dev/null +++ b/vendor/image-size/dist/index.d.ts @@ -0,0 +1,10 @@ +import type { imageType } from './types/index'; +import type { ISizeCalculationResult } from './types/interface'; +type CallbackFn = (e: Error | null, r?: ISizeCalculationResult) => void; +export default imageSize; +export declare function imageSize(input: Uint8Array | string): ISizeCalculationResult; +export declare function imageSize(input: string, callback: CallbackFn): void; +export declare const disableFS: (v: boolean) => void; +export declare const disableTypes: (types: imageType[]) => void; +export declare const setConcurrency: (c: number) => void; +export declare const types: string[]; diff --git a/vendor/image-size/dist/index.js b/vendor/image-size/dist/index.js new file mode 100644 index 0000000000..6b0ca64fd5 --- /dev/null +++ b/vendor/image-size/dist/index.js @@ -0,0 +1,129 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.types = exports.setConcurrency = exports.disableTypes = exports.disableFS = exports.imageSize = void 0; +const fs = require("fs"); +const path = require("path"); +const queue_1 = require("queue"); +const index_1 = require("./types/index"); +const detector_1 = require("./detector"); +// Maximum input size, with a default of 512 kilobytes. +// TO-DO: make this adaptive based on the initial signature of the image +const MaxInputSize = 512 * 1024; +// This queue is for async `fs` operations, to avoid reaching file-descriptor limits +const queue = new queue_1.default({ concurrency: 100, autostart: true }); +const globalOptions = { + disabledFS: false, + disabledTypes: [], +}; +/** + * Return size information based on an Uint8Array + * + * @param {Uint8Array} input + * @param {String} filepath + * @returns {Object} + */ +function lookup(input, filepath) { + // detect the file type.. don't rely on the extension + const type = (0, detector_1.detector)(input); + if (typeof type !== 'undefined') { + if (globalOptions.disabledTypes.indexOf(type) > -1) { + throw new TypeError('disabled file type: ' + type); + } + // find an appropriate handler for this file type + if (type in index_1.typeHandlers) { + const size = index_1.typeHandlers[type].calculate(input, filepath); + if (size !== undefined) { + size.type = size.type ?? type; + return size; + } + } + } + // throw up, if we don't understand the file + throw new TypeError('unsupported file type: ' + type + ' (file: ' + filepath + ')'); +} +/** + * Reads a file into an Uint8Array. + * @param {String} filepath + * @returns {Promise} + */ +async function readFileAsync(filepath) { + const handle = await fs.promises.open(filepath, 'r'); + try { + const { size } = await handle.stat(); + if (size <= 0) { + throw new Error('Empty file'); + } + const inputSize = Math.min(size, MaxInputSize); + const input = new Uint8Array(inputSize); + await handle.read(input, 0, inputSize, 0); + return input; + } + finally { + await handle.close(); + } +} +/** + * Synchronously reads a file into an Uint8Array, blocking the nodejs process. + * + * @param {String} filepath + * @returns {Uint8Array} + */ +function readFileSync(filepath) { + // read from the file, synchronously + const descriptor = fs.openSync(filepath, 'r'); + try { + const { size } = fs.fstatSync(descriptor); + if (size <= 0) { + throw new Error('Empty file'); + } + const inputSize = Math.min(size, MaxInputSize); + const input = new Uint8Array(inputSize); + fs.readSync(descriptor, input, 0, inputSize, 0); + return input; + } + finally { + fs.closeSync(descriptor); + } +} +// eslint-disable-next-line @typescript-eslint/no-use-before-define +module.exports = exports = imageSize; // backwards compatibility +exports.default = imageSize; +/** + * @param {Uint8Array|string} input - Uint8Array or relative/absolute path of the image file + * @param {Function=} [callback] - optional function for async detection + */ +function imageSize(input, callback) { + // Handle Uint8Array input + if (input instanceof Uint8Array) { + return lookup(input); + } + // input should be a string at this point + if (typeof input !== 'string' || globalOptions.disabledFS) { + throw new TypeError('invalid invocation. input should be a Uint8Array'); + } + // resolve the file path + const filepath = path.resolve(input); + if (typeof callback === 'function') { + queue.push(() => readFileAsync(filepath) + .then((input) => process.nextTick(callback, null, lookup(input, filepath))) + .catch(callback)); + } + else { + const input = readFileSync(filepath); + return lookup(input, filepath); + } +} +exports.imageSize = imageSize; +const disableFS = (v) => { + globalOptions.disabledFS = v; +}; +exports.disableFS = disableFS; +const disableTypes = (types) => { + globalOptions.disabledTypes = types; +}; +exports.disableTypes = disableTypes; +const setConcurrency = (c) => { + queue.concurrency = c; +}; +exports.setConcurrency = setConcurrency; +exports.types = Object.keys(index_1.typeHandlers); diff --git a/vendor/image-size/dist/types/bmp.d.ts b/vendor/image-size/dist/types/bmp.d.ts new file mode 100644 index 0000000000..be1d22e1ef --- /dev/null +++ b/vendor/image-size/dist/types/bmp.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const BMP: IImage; diff --git a/vendor/image-size/dist/types/bmp.js b/vendor/image-size/dist/types/bmp.js new file mode 100644 index 0000000000..6f53b50d25 --- /dev/null +++ b/vendor/image-size/dist/types/bmp.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BMP = void 0; +const utils_1 = require("./utils"); +exports.BMP = { + validate: (input) => (0, utils_1.toUTF8String)(input, 0, 2) === 'BM', + calculate: (input) => ({ + height: Math.abs((0, utils_1.readInt32LE)(input, 22)), + width: (0, utils_1.readUInt32LE)(input, 18), + }), +}; diff --git a/vendor/image-size/dist/types/cur.d.ts b/vendor/image-size/dist/types/cur.d.ts new file mode 100644 index 0000000000..fc0dada330 --- /dev/null +++ b/vendor/image-size/dist/types/cur.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const CUR: IImage; diff --git a/vendor/image-size/dist/types/cur.js b/vendor/image-size/dist/types/cur.js new file mode 100644 index 0000000000..7bcc512598 --- /dev/null +++ b/vendor/image-size/dist/types/cur.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CUR = void 0; +const ico_1 = require("./ico"); +const utils_1 = require("./utils"); +const TYPE_CURSOR = 2; +exports.CUR = { + validate(input) { + const reserved = (0, utils_1.readUInt16LE)(input, 0); + const imageCount = (0, utils_1.readUInt16LE)(input, 4); + if (reserved !== 0 || imageCount === 0) + return false; + const imageType = (0, utils_1.readUInt16LE)(input, 2); + return imageType === TYPE_CURSOR; + }, + calculate: (input) => ico_1.ICO.calculate(input), +}; diff --git a/vendor/image-size/dist/types/dds.d.ts b/vendor/image-size/dist/types/dds.d.ts new file mode 100644 index 0000000000..46e9246451 --- /dev/null +++ b/vendor/image-size/dist/types/dds.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const DDS: IImage; diff --git a/vendor/image-size/dist/types/dds.js b/vendor/image-size/dist/types/dds.js new file mode 100644 index 0000000000..67f0b79d65 --- /dev/null +++ b/vendor/image-size/dist/types/dds.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DDS = void 0; +const utils_1 = require("./utils"); +exports.DDS = { + validate: (input) => (0, utils_1.readUInt32LE)(input, 0) === 0x20534444, + calculate: (input) => ({ + height: (0, utils_1.readUInt32LE)(input, 12), + width: (0, utils_1.readUInt32LE)(input, 16), + }), +}; diff --git a/vendor/image-size/dist/types/gif.d.ts b/vendor/image-size/dist/types/gif.d.ts new file mode 100644 index 0000000000..68445984f9 --- /dev/null +++ b/vendor/image-size/dist/types/gif.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const GIF: IImage; diff --git a/vendor/image-size/dist/types/gif.js b/vendor/image-size/dist/types/gif.js new file mode 100644 index 0000000000..d826c5c6f1 --- /dev/null +++ b/vendor/image-size/dist/types/gif.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GIF = void 0; +const utils_1 = require("./utils"); +const gifRegexp = /^GIF8[79]a/; +exports.GIF = { + validate: (input) => gifRegexp.test((0, utils_1.toUTF8String)(input, 0, 6)), + calculate: (input) => ({ + height: (0, utils_1.readUInt16LE)(input, 8), + width: (0, utils_1.readUInt16LE)(input, 6), + }), +}; diff --git a/vendor/image-size/dist/types/heif.d.ts b/vendor/image-size/dist/types/heif.d.ts new file mode 100644 index 0000000000..3d8893753a --- /dev/null +++ b/vendor/image-size/dist/types/heif.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const HEIF: IImage; diff --git a/vendor/image-size/dist/types/heif.js b/vendor/image-size/dist/types/heif.js new file mode 100644 index 0000000000..7997d3f159 --- /dev/null +++ b/vendor/image-size/dist/types/heif.js @@ -0,0 +1,40 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.HEIF = void 0; +const utils_1 = require("./utils"); +const brandMap = { + avif: 'avif', + mif1: 'heif', + msf1: 'heif', // heif-sequence + heic: 'heic', + heix: 'heic', + hevc: 'heic', // heic-sequence + hevx: 'heic', // heic-sequence +}; +exports.HEIF = { + validate(input) { + const boxType = (0, utils_1.toUTF8String)(input, 4, 8); + if (boxType !== 'ftyp') + return false; + const ftypBox = (0, utils_1.findBox)(input, 'ftyp', 0); + if (!ftypBox) + return false; + const brand = (0, utils_1.toUTF8String)(input, ftypBox.offset + 8, ftypBox.offset + 12); + return brand in brandMap; + }, + calculate(input) { + // Based on https://nokiatech.github.io/heif/technical.html + const metaBox = (0, utils_1.findBox)(input, 'meta', 0); + const iprpBox = metaBox && (0, utils_1.findBox)(input, 'iprp', metaBox.offset + 12); + const ipcoBox = iprpBox && (0, utils_1.findBox)(input, 'ipco', iprpBox.offset + 8); + const ispeBox = ipcoBox && (0, utils_1.findBox)(input, 'ispe', ipcoBox.offset + 8); + if (ispeBox) { + return { + height: (0, utils_1.readUInt32BE)(input, ispeBox.offset + 16), + width: (0, utils_1.readUInt32BE)(input, ispeBox.offset + 12), + type: (0, utils_1.toUTF8String)(input, 8, 12), + }; + } + throw new TypeError('Invalid HEIF, no size found'); + }, +}; diff --git a/vendor/image-size/dist/types/icns.d.ts b/vendor/image-size/dist/types/icns.d.ts new file mode 100644 index 0000000000..40701d4e90 --- /dev/null +++ b/vendor/image-size/dist/types/icns.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const ICNS: IImage; diff --git a/vendor/image-size/dist/types/icns.js b/vendor/image-size/dist/types/icns.js new file mode 100644 index 0000000000..6ddabf0c35 --- /dev/null +++ b/vendor/image-size/dist/types/icns.js @@ -0,0 +1,109 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ICNS = void 0; +const utils_1 = require("./utils"); +/** + * ICNS Header + * + * | Offset | Size | Purpose | + * | 0 | 4 | Magic literal, must be "icns" (0x69, 0x63, 0x6e, 0x73) | + * | 4 | 4 | Length of file, in bytes, msb first. | + * + */ +const SIZE_HEADER = 4 + 4; // 8 +const FILE_LENGTH_OFFSET = 4; // MSB => BIG ENDIAN +const MIN_ENTRY_LENGTH = 4 + 4; // type + length +/** + * Image Entry + * + * | Offset | Size | Purpose | + * | 0 | 4 | Icon type, see OSType below. | + * | 4 | 4 | Length of data, in bytes (including type and length), msb first. | + * | 8 | n | Icon data | + */ +const ENTRY_LENGTH_OFFSET = 4; // MSB => BIG ENDIAN +const ICON_TYPE_SIZE = { + ICON: 32, + 'ICN#': 32, + // m => 16 x 16 + 'icm#': 16, + icm4: 16, + icm8: 16, + // s => 16 x 16 + 'ics#': 16, + ics4: 16, + ics8: 16, + is32: 16, + s8mk: 16, + icp4: 16, + // l => 32 x 32 + icl4: 32, + icl8: 32, + il32: 32, + l8mk: 32, + icp5: 32, + ic11: 32, + // h => 48 x 48 + ich4: 48, + ich8: 48, + ih32: 48, + h8mk: 48, + // . => 64 x 64 + icp6: 64, + ic12: 32, + // t => 128 x 128 + it32: 128, + t8mk: 128, + ic07: 128, + // . => 256 x 256 + ic08: 256, + ic13: 256, + // . => 512 x 512 + ic09: 512, + ic14: 512, + // . => 1024 x 1024 + ic10: 1024, +}; +function readImageHeader(input, imageOffset) { + if (input.length - imageOffset < MIN_ENTRY_LENGTH) { + throw new TypeError('Invalid ICNS entry header'); + } + const imageLengthOffset = imageOffset + ENTRY_LENGTH_OFFSET; + const imageLength = (0, utils_1.readUInt32BE)(input, imageLengthOffset); + if (imageLength < MIN_ENTRY_LENGTH) { + throw new TypeError('Invalid ICNS entry length'); + } + return [ + (0, utils_1.toUTF8String)(input, imageOffset, imageLengthOffset), + imageLength, + ]; +} +function getImageSize(type) { + const size = ICON_TYPE_SIZE[type]; + return { width: size, height: size, type }; +} +exports.ICNS = { + validate: (input) => (0, utils_1.toUTF8String)(input, 0, 4) === 'icns', + calculate(input) { + const inputLength = input.length; + const fileLength = (0, utils_1.readUInt32BE)(input, FILE_LENGTH_OFFSET); + let imageOffset = SIZE_HEADER; + let imageHeader = readImageHeader(input, imageOffset); + let imageSize = getImageSize(imageHeader[0]); + imageOffset += imageHeader[1]; + if (imageOffset === fileLength) + return imageSize; + const result = { + height: imageSize.height, + images: [imageSize], + width: imageSize.width, + }; + while (imageOffset < fileLength && imageOffset < inputLength) { + imageHeader = readImageHeader(input, imageOffset); + imageSize = getImageSize(imageHeader[0]); + imageOffset += imageHeader[1]; + result.images.push(imageSize); + } + return result; + }, +}; diff --git a/vendor/image-size/dist/types/ico.d.ts b/vendor/image-size/dist/types/ico.d.ts new file mode 100644 index 0000000000..a378b827db --- /dev/null +++ b/vendor/image-size/dist/types/ico.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const ICO: IImage; diff --git a/vendor/image-size/dist/types/ico.js b/vendor/image-size/dist/types/ico.js new file mode 100644 index 0000000000..0c630a0871 --- /dev/null +++ b/vendor/image-size/dist/types/ico.js @@ -0,0 +1,68 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ICO = void 0; +const utils_1 = require("./utils"); +const TYPE_ICON = 1; +/** + * ICON Header + * + * | Offset | Size | Purpose | + * | 0 | 2 | Reserved. Must always be 0. | + * | 2 | 2 | Image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid. | + * | 4 | 2 | Number of images in the file. | + * + */ +const SIZE_HEADER = 2 + 2 + 2; // 6 +/** + * Image Entry + * + * | Offset | Size | Purpose | + * | 0 | 1 | Image width in pixels. Can be any number between 0 and 255. Value 0 means width is 256 pixels. | + * | 1 | 1 | Image height in pixels. Can be any number between 0 and 255. Value 0 means height is 256 pixels. | + * | 2 | 1 | Number of colors in the color palette. Should be 0 if the image does not use a color palette. | + * | 3 | 1 | Reserved. Should be 0. | + * | 4 | 2 | ICO format: Color planes. Should be 0 or 1. | + * | | | CUR format: The horizontal coordinates of the hotspot in number of pixels from the left. | + * | 6 | 2 | ICO format: Bits per pixel. | + * | | | CUR format: The vertical coordinates of the hotspot in number of pixels from the top. | + * | 8 | 4 | The size of the image's data in bytes | + * | 12 | 4 | The offset of BMP or PNG data from the beginning of the ICO/CUR file | + * + */ +const SIZE_IMAGE_ENTRY = 1 + 1 + 1 + 1 + 2 + 2 + 4 + 4; // 16 +function getSizeFromOffset(input, offset) { + const value = input[offset]; + return value === 0 ? 256 : value; +} +function getImageSize(input, imageIndex) { + const offset = SIZE_HEADER + imageIndex * SIZE_IMAGE_ENTRY; + return { + height: getSizeFromOffset(input, offset + 1), + width: getSizeFromOffset(input, offset), + }; +} +exports.ICO = { + validate(input) { + const reserved = (0, utils_1.readUInt16LE)(input, 0); + const imageCount = (0, utils_1.readUInt16LE)(input, 4); + if (reserved !== 0 || imageCount === 0) + return false; + const imageType = (0, utils_1.readUInt16LE)(input, 2); + return imageType === TYPE_ICON; + }, + calculate(input) { + const nbImages = (0, utils_1.readUInt16LE)(input, 4); + const imageSize = getImageSize(input, 0); + if (nbImages === 1) + return imageSize; + const imgs = [imageSize]; + for (let imageIndex = 1; imageIndex < nbImages; imageIndex += 1) { + imgs.push(getImageSize(input, imageIndex)); + } + return { + height: imageSize.height, + images: imgs, + width: imageSize.width, + }; + }, +}; diff --git a/vendor/image-size/dist/types/index.d.ts b/vendor/image-size/dist/types/index.d.ts new file mode 100644 index 0000000000..e338d1f8d7 --- /dev/null +++ b/vendor/image-size/dist/types/index.d.ts @@ -0,0 +1,23 @@ +export declare const typeHandlers: { + bmp: import("./interface").IImage; + cur: import("./interface").IImage; + dds: import("./interface").IImage; + gif: import("./interface").IImage; + heif: import("./interface").IImage; + icns: import("./interface").IImage; + ico: import("./interface").IImage; + j2c: import("./interface").IImage; + jp2: import("./interface").IImage; + jpg: import("./interface").IImage; + jxl: import("./interface").IImage; + 'jxl-stream': import("./interface").IImage; + ktx: import("./interface").IImage; + png: import("./interface").IImage; + pnm: import("./interface").IImage; + psd: import("./interface").IImage; + svg: import("./interface").IImage; + tga: import("./interface").IImage; + tiff: import("./interface").IImage; + webp: import("./interface").IImage; +}; +export type imageType = keyof typeof typeHandlers; diff --git a/vendor/image-size/dist/types/index.js b/vendor/image-size/dist/types/index.js new file mode 100644 index 0000000000..f7cce831e1 --- /dev/null +++ b/vendor/image-size/dist/types/index.js @@ -0,0 +1,46 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.typeHandlers = void 0; +// load all available handlers explicitly for browserify support +const bmp_1 = require("./bmp"); +const cur_1 = require("./cur"); +const dds_1 = require("./dds"); +const gif_1 = require("./gif"); +const heif_1 = require("./heif"); +const icns_1 = require("./icns"); +const ico_1 = require("./ico"); +const j2c_1 = require("./j2c"); +const jp2_1 = require("./jp2"); +const jpg_1 = require("./jpg"); +const jxl_1 = require("./jxl"); +const jxl_stream_1 = require("./jxl-stream"); +const ktx_1 = require("./ktx"); +const png_1 = require("./png"); +const pnm_1 = require("./pnm"); +const psd_1 = require("./psd"); +const svg_1 = require("./svg"); +const tga_1 = require("./tga"); +const tiff_1 = require("./tiff"); +const webp_1 = require("./webp"); +exports.typeHandlers = { + bmp: bmp_1.BMP, + cur: cur_1.CUR, + dds: dds_1.DDS, + gif: gif_1.GIF, + heif: heif_1.HEIF, + icns: icns_1.ICNS, + ico: ico_1.ICO, + j2c: j2c_1.J2C, + jp2: jp2_1.JP2, + jpg: jpg_1.JPG, + jxl: jxl_1.JXL, + 'jxl-stream': jxl_stream_1.JXLStream, + ktx: ktx_1.KTX, + png: png_1.PNG, + pnm: pnm_1.PNM, + psd: psd_1.PSD, + svg: svg_1.SVG, + tga: tga_1.TGA, + tiff: tiff_1.TIFF, + webp: webp_1.WEBP, +}; diff --git a/vendor/image-size/dist/types/interface.d.ts b/vendor/image-size/dist/types/interface.d.ts new file mode 100644 index 0000000000..96dc89bad1 --- /dev/null +++ b/vendor/image-size/dist/types/interface.d.ts @@ -0,0 +1,13 @@ +export interface ISize { + width: number | undefined; + height: number | undefined; + orientation?: number; + type?: string; +} +export type ISizeCalculationResult = { + images?: ISize[]; +} & ISize; +export interface IImage { + validate: (input: Uint8Array) => boolean; + calculate: (input: Uint8Array, filepath?: string) => ISizeCalculationResult; +} diff --git a/vendor/image-size/dist/types/interface.js b/vendor/image-size/dist/types/interface.js new file mode 100644 index 0000000000..c8ad2e549b --- /dev/null +++ b/vendor/image-size/dist/types/interface.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/vendor/image-size/dist/types/j2c.d.ts b/vendor/image-size/dist/types/j2c.d.ts new file mode 100644 index 0000000000..f745c7b5ec --- /dev/null +++ b/vendor/image-size/dist/types/j2c.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const J2C: IImage; diff --git a/vendor/image-size/dist/types/j2c.js b/vendor/image-size/dist/types/j2c.js new file mode 100644 index 0000000000..fba00fcc95 --- /dev/null +++ b/vendor/image-size/dist/types/j2c.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.J2C = void 0; +const utils_1 = require("./utils"); +exports.J2C = { + // TODO: this doesn't seem right. SIZ marker doesn't have to be right after the SOC + validate: (input) => (0, utils_1.readUInt32BE)(input, 0) === 0xff4fff51, + calculate: (input) => ({ + height: (0, utils_1.readUInt32BE)(input, 12), + width: (0, utils_1.readUInt32BE)(input, 8), + }), +}; diff --git a/vendor/image-size/dist/types/jp2.d.ts b/vendor/image-size/dist/types/jp2.d.ts new file mode 100644 index 0000000000..ce53bb3b1f --- /dev/null +++ b/vendor/image-size/dist/types/jp2.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const JP2: IImage; diff --git a/vendor/image-size/dist/types/jp2.js b/vendor/image-size/dist/types/jp2.js new file mode 100644 index 0000000000..8af3f77cb5 --- /dev/null +++ b/vendor/image-size/dist/types/jp2.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JP2 = void 0; +const utils_1 = require("./utils"); +exports.JP2 = { + validate(input) { + const boxType = (0, utils_1.toUTF8String)(input, 4, 8); + if (boxType !== 'jP ') + return false; + const ftypBox = (0, utils_1.findBox)(input, 'ftyp', 0); + if (!ftypBox) + return false; + const brand = (0, utils_1.toUTF8String)(input, ftypBox.offset + 8, ftypBox.offset + 12); + return brand === 'jp2 '; + }, + calculate(input) { + const jp2hBox = (0, utils_1.findBox)(input, 'jp2h', 0); + const ihdrBox = jp2hBox && (0, utils_1.findBox)(input, 'ihdr', jp2hBox.offset + 8); + if (ihdrBox) { + return { + height: (0, utils_1.readUInt32BE)(input, ihdrBox.offset + 8), + width: (0, utils_1.readUInt32BE)(input, ihdrBox.offset + 12), + }; + } + throw new TypeError('Unsupported JPEG 2000 format'); + }, +}; diff --git a/vendor/image-size/dist/types/jpg.d.ts b/vendor/image-size/dist/types/jpg.d.ts new file mode 100644 index 0000000000..68fc201693 --- /dev/null +++ b/vendor/image-size/dist/types/jpg.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const JPG: IImage; diff --git a/vendor/image-size/dist/types/jpg.js b/vendor/image-size/dist/types/jpg.js new file mode 100644 index 0000000000..e6f0ecec39 --- /dev/null +++ b/vendor/image-size/dist/types/jpg.js @@ -0,0 +1,123 @@ +"use strict"; +// NOTE: we only support baseline and progressive JPGs here +// due to the structure of the loader class, we only get a buffer +// with a maximum size of 4096 bytes. so if the SOF marker is outside +// if this range we can't detect the file size correctly. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JPG = void 0; +const utils_1 = require("./utils"); +const EXIF_MARKER = '45786966'; +const APP1_DATA_SIZE_BYTES = 2; +const EXIF_HEADER_BYTES = 6; +const TIFF_BYTE_ALIGN_BYTES = 2; +const BIG_ENDIAN_BYTE_ALIGN = '4d4d'; +const LITTLE_ENDIAN_BYTE_ALIGN = '4949'; +// Each entry is exactly 12 bytes +const IDF_ENTRY_BYTES = 12; +const NUM_DIRECTORY_ENTRIES_BYTES = 2; +function isEXIF(input) { + return (0, utils_1.toHexString)(input, 2, 6) === EXIF_MARKER; +} +function extractSize(input, index) { + return { + height: (0, utils_1.readUInt16BE)(input, index), + width: (0, utils_1.readUInt16BE)(input, index + 2), + }; +} +function extractOrientation(exifBlock, isBigEndian) { + // TODO: assert that this contains 0x002A + // let STATIC_MOTOROLA_TIFF_HEADER_BYTES = 2 + // let TIFF_IMAGE_FILE_DIRECTORY_BYTES = 4 + // TODO: derive from TIFF_IMAGE_FILE_DIRECTORY_BYTES + const idfOffset = 8; + // IDF osset works from right after the header bytes + // (so the offset includes the tiff byte align) + const offset = EXIF_HEADER_BYTES + idfOffset; + const idfDirectoryEntries = (0, utils_1.readUInt)(exifBlock, 16, offset, isBigEndian); + for (let directoryEntryNumber = 0; directoryEntryNumber < idfDirectoryEntries; directoryEntryNumber++) { + const start = offset + + NUM_DIRECTORY_ENTRIES_BYTES + + directoryEntryNumber * IDF_ENTRY_BYTES; + const end = start + IDF_ENTRY_BYTES; + // Skip on corrupt EXIF blocks + if (start > exifBlock.length) { + return; + } + const block = exifBlock.slice(start, end); + const tagNumber = (0, utils_1.readUInt)(block, 16, 0, isBigEndian); + // 0x0112 (decimal: 274) is the `orientation` tag ID + if (tagNumber === 274) { + const dataFormat = (0, utils_1.readUInt)(block, 16, 2, isBigEndian); + if (dataFormat !== 3) { + return; + } + // unsinged int has 2 bytes per component + // if there would more than 4 bytes in total it's a pointer + const numberOfComponents = (0, utils_1.readUInt)(block, 32, 4, isBigEndian); + if (numberOfComponents !== 1) { + return; + } + return (0, utils_1.readUInt)(block, 16, 8, isBigEndian); + } + } +} +function validateExifBlock(input, index) { + // Skip APP1 Data Size + const exifBlock = input.slice(APP1_DATA_SIZE_BYTES, index); + // Consider byte alignment + const byteAlign = (0, utils_1.toHexString)(exifBlock, EXIF_HEADER_BYTES, EXIF_HEADER_BYTES + TIFF_BYTE_ALIGN_BYTES); + // Ignore Empty EXIF. Validate byte alignment + const isBigEndian = byteAlign === BIG_ENDIAN_BYTE_ALIGN; + const isLittleEndian = byteAlign === LITTLE_ENDIAN_BYTE_ALIGN; + if (isBigEndian || isLittleEndian) { + return extractOrientation(exifBlock, isBigEndian); + } +} +function validateInput(input, index) { + // index should be within buffer limits + if (index > input.length) { + throw new TypeError('Corrupt JPG, exceeded buffer limits'); + } +} +exports.JPG = { + validate: (input) => (0, utils_1.toHexString)(input, 0, 2) === 'ffd8', + calculate(input) { + // Skip 4 chars, they are for signature + input = input.slice(4); + let orientation; + let next; + while (input.length) { + // read length of the next block + const i = (0, utils_1.readUInt16BE)(input, 0); + // Every JPEG block must begin with a 0xFF + if (input[i] !== 0xff) { + input = input.slice(1); + continue; + } + if (isEXIF(input)) { + orientation = validateExifBlock(input, i); + } + // ensure correct format + validateInput(input, i); + // 0xFFC0 is baseline standard(SOF) + // 0xFFC1 is baseline optimized(SOF) + // 0xFFC2 is progressive(SOF2) + next = input[i + 1]; + if (next === 0xc0 || next === 0xc1 || next === 0xc2) { + const size = extractSize(input, i + 5); + // TODO: is orientation=0 a valid answer here? + if (!orientation) { + return size; + } + return { + height: size.height, + orientation, + width: size.width, + }; + } + // move to the next block + input = input.slice(i + 2); + } + throw new TypeError('Invalid JPG, no size found'); + }, +}; diff --git a/vendor/image-size/dist/types/jxl-stream.d.ts b/vendor/image-size/dist/types/jxl-stream.d.ts new file mode 100644 index 0000000000..1c502bb2e5 --- /dev/null +++ b/vendor/image-size/dist/types/jxl-stream.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const JXLStream: IImage; diff --git a/vendor/image-size/dist/types/jxl-stream.js b/vendor/image-size/dist/types/jxl-stream.js new file mode 100644 index 0000000000..e73316b41f --- /dev/null +++ b/vendor/image-size/dist/types/jxl-stream.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JXLStream = void 0; +const utils_1 = require("./utils"); +const bit_reader_1 = require("../utils/bit-reader"); +function calculateImageDimension(reader, isSmallImage) { + if (isSmallImage) { + // Small images are multiples of 8 pixels, up to 256 pixels + return 8 * (1 + reader.getBits(5)); + } + else { + // Larger images use a variable bit-length encoding + const sizeClass = reader.getBits(2); + const extraBits = [9, 13, 18, 30][sizeClass]; + return 1 + reader.getBits(extraBits); + } +} +function calculateImageWidth(reader, isSmallImage, widthMode, height) { + if (isSmallImage && widthMode === 0) { + // Small square images + return 8 * (1 + reader.getBits(5)); + } + else if (widthMode === 0) { + // Non-small images with explicitly coded width + return calculateImageDimension(reader, false); + } + else { + // Images with width derived from height and aspect ratio + const aspectRatios = [1, 1.2, 4 / 3, 1.5, 16 / 9, 5 / 4, 2]; + return Math.floor(height * aspectRatios[widthMode - 1]); + } +} +exports.JXLStream = { + validate: (input) => { + return (0, utils_1.toHexString)(input, 0, 2) === 'ff0a'; + }, + calculate(input) { + const reader = new bit_reader_1.BitReader(input, 'little-endian'); + const isSmallImage = reader.getBits(1) === 1; + const height = calculateImageDimension(reader, isSmallImage); + const widthMode = reader.getBits(3); + const width = calculateImageWidth(reader, isSmallImage, widthMode, height); + return { width, height }; + }, +}; diff --git a/vendor/image-size/dist/types/jxl.d.ts b/vendor/image-size/dist/types/jxl.d.ts new file mode 100644 index 0000000000..cdec897e1a --- /dev/null +++ b/vendor/image-size/dist/types/jxl.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const JXL: IImage; diff --git a/vendor/image-size/dist/types/jxl.js b/vendor/image-size/dist/types/jxl.js new file mode 100644 index 0000000000..f557b0aea7 --- /dev/null +++ b/vendor/image-size/dist/types/jxl.js @@ -0,0 +1,59 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JXL = void 0; +const utils_1 = require("./utils"); +const jxl_stream_1 = require("./jxl-stream"); +/** Extracts the codestream from a containerized JPEG XL image */ +function extractCodestream(input) { + const jxlcBox = (0, utils_1.findBox)(input, 'jxlc', 0); + if (jxlcBox) { + return input.slice(jxlcBox.offset + 8, jxlcBox.offset + jxlcBox.size); + } + const partialStreams = extractPartialStreams(input); + if (partialStreams.length > 0) { + return concatenateCodestreams(partialStreams); + } + return undefined; +} +/** Extracts partial codestreams from jxlp boxes */ +function extractPartialStreams(input) { + const partialStreams = []; + let offset = 0; + while (offset < input.length) { + const jxlpBox = (0, utils_1.findBox)(input, 'jxlp', offset); + if (!jxlpBox) + break; + partialStreams.push(input.slice(jxlpBox.offset + 12, jxlpBox.offset + jxlpBox.size)); + offset = jxlpBox.offset + jxlpBox.size; + } + return partialStreams; +} +/** Concatenates partial codestreams into a single codestream */ +function concatenateCodestreams(partialCodestreams) { + const totalLength = partialCodestreams.reduce((acc, curr) => acc + curr.length, 0); + const codestream = new Uint8Array(totalLength); + let position = 0; + for (const partial of partialCodestreams) { + codestream.set(partial, position); + position += partial.length; + } + return codestream; +} +exports.JXL = { + validate: (input) => { + const boxType = (0, utils_1.toUTF8String)(input, 4, 8); + if (boxType !== 'JXL ') + return false; + const ftypBox = (0, utils_1.findBox)(input, 'ftyp', 0); + if (!ftypBox) + return false; + const brand = (0, utils_1.toUTF8String)(input, ftypBox.offset + 8, ftypBox.offset + 12); + return brand === 'jxl '; + }, + calculate(input) { + const codestream = extractCodestream(input); + if (codestream) + return jxl_stream_1.JXLStream.calculate(codestream); + throw new Error('No codestream found in JXL container'); + }, +}; diff --git a/vendor/image-size/dist/types/ktx.d.ts b/vendor/image-size/dist/types/ktx.d.ts new file mode 100644 index 0000000000..48fb6c95e6 --- /dev/null +++ b/vendor/image-size/dist/types/ktx.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const KTX: IImage; diff --git a/vendor/image-size/dist/types/ktx.js b/vendor/image-size/dist/types/ktx.js new file mode 100644 index 0000000000..e3f6381d2c --- /dev/null +++ b/vendor/image-size/dist/types/ktx.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KTX = void 0; +const utils_1 = require("./utils"); +exports.KTX = { + validate: (input) => { + const signature = (0, utils_1.toUTF8String)(input, 1, 7); + return ['KTX 11', 'KTX 20'].includes(signature); + }, + calculate: (input) => { + const type = input[5] === 0x31 ? 'ktx' : 'ktx2'; + const offset = type === 'ktx' ? 36 : 20; + return { + height: (0, utils_1.readUInt32LE)(input, offset + 4), + width: (0, utils_1.readUInt32LE)(input, offset), + type, + }; + }, +}; diff --git a/vendor/image-size/dist/types/png.d.ts b/vendor/image-size/dist/types/png.d.ts new file mode 100644 index 0000000000..53415c0148 --- /dev/null +++ b/vendor/image-size/dist/types/png.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const PNG: IImage; diff --git a/vendor/image-size/dist/types/png.js b/vendor/image-size/dist/types/png.js new file mode 100644 index 0000000000..b8aff59e82 --- /dev/null +++ b/vendor/image-size/dist/types/png.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PNG = void 0; +const utils_1 = require("./utils"); +const pngSignature = 'PNG\r\n\x1a\n'; +const pngImageHeaderChunkName = 'IHDR'; +// Used to detect "fried" png's: http://www.jongware.com/pngdefry.html +const pngFriedChunkName = 'CgBI'; +exports.PNG = { + validate(input) { + if (pngSignature === (0, utils_1.toUTF8String)(input, 1, 8)) { + let chunkName = (0, utils_1.toUTF8String)(input, 12, 16); + if (chunkName === pngFriedChunkName) { + chunkName = (0, utils_1.toUTF8String)(input, 28, 32); + } + if (chunkName !== pngImageHeaderChunkName) { + throw new TypeError('Invalid PNG'); + } + return true; + } + return false; + }, + calculate(input) { + if ((0, utils_1.toUTF8String)(input, 12, 16) === pngFriedChunkName) { + return { + height: (0, utils_1.readUInt32BE)(input, 36), + width: (0, utils_1.readUInt32BE)(input, 32), + }; + } + return { + height: (0, utils_1.readUInt32BE)(input, 20), + width: (0, utils_1.readUInt32BE)(input, 16), + }; + }, +}; diff --git a/vendor/image-size/dist/types/pnm.d.ts b/vendor/image-size/dist/types/pnm.d.ts new file mode 100644 index 0000000000..13950cb49a --- /dev/null +++ b/vendor/image-size/dist/types/pnm.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const PNM: IImage; diff --git a/vendor/image-size/dist/types/pnm.js b/vendor/image-size/dist/types/pnm.js new file mode 100644 index 0000000000..d61295a793 --- /dev/null +++ b/vendor/image-size/dist/types/pnm.js @@ -0,0 +1,72 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PNM = void 0; +const utils_1 = require("./utils"); +const PNMTypes = { + P1: 'pbm/ascii', + P2: 'pgm/ascii', + P3: 'ppm/ascii', + P4: 'pbm', + P5: 'pgm', + P6: 'ppm', + P7: 'pam', + PF: 'pfm', +}; +const handlers = { + default: (lines) => { + let dimensions = []; + while (lines.length > 0) { + const line = lines.shift(); + if (line[0] === '#') { + continue; + } + dimensions = line.split(' '); + break; + } + if (dimensions.length === 2) { + return { + height: parseInt(dimensions[1], 10), + width: parseInt(dimensions[0], 10), + }; + } + else { + throw new TypeError('Invalid PNM'); + } + }, + pam: (lines) => { + const size = {}; + while (lines.length > 0) { + const line = lines.shift(); + if (line.length > 16 || line.charCodeAt(0) > 128) { + continue; + } + const [key, value] = line.split(' '); + if (key && value) { + size[key.toLowerCase()] = parseInt(value, 10); + } + if (size.height && size.width) { + break; + } + } + if (size.height && size.width) { + return { + height: size.height, + width: size.width, + }; + } + else { + throw new TypeError('Invalid PAM'); + } + }, +}; +exports.PNM = { + validate: (input) => (0, utils_1.toUTF8String)(input, 0, 2) in PNMTypes, + calculate(input) { + const signature = (0, utils_1.toUTF8String)(input, 0, 2); + const type = PNMTypes[signature]; + // TODO: this probably generates garbage. move to a stream based parser + const lines = (0, utils_1.toUTF8String)(input, 3).split(/[\r\n]+/); + const handler = handlers[type] || handlers.default; + return handler(lines); + }, +}; diff --git a/vendor/image-size/dist/types/psd.d.ts b/vendor/image-size/dist/types/psd.d.ts new file mode 100644 index 0000000000..5f5c141646 --- /dev/null +++ b/vendor/image-size/dist/types/psd.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const PSD: IImage; diff --git a/vendor/image-size/dist/types/psd.js b/vendor/image-size/dist/types/psd.js new file mode 100644 index 0000000000..6b328569fb --- /dev/null +++ b/vendor/image-size/dist/types/psd.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PSD = void 0; +const utils_1 = require("./utils"); +exports.PSD = { + validate: (input) => (0, utils_1.toUTF8String)(input, 0, 4) === '8BPS', + calculate: (input) => ({ + height: (0, utils_1.readUInt32BE)(input, 14), + width: (0, utils_1.readUInt32BE)(input, 18), + }), +}; diff --git a/vendor/image-size/dist/types/svg.d.ts b/vendor/image-size/dist/types/svg.d.ts new file mode 100644 index 0000000000..0a10be8240 --- /dev/null +++ b/vendor/image-size/dist/types/svg.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const SVG: IImage; diff --git a/vendor/image-size/dist/types/svg.js b/vendor/image-size/dist/types/svg.js new file mode 100644 index 0000000000..fb80a9730b --- /dev/null +++ b/vendor/image-size/dist/types/svg.js @@ -0,0 +1,90 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SVG = void 0; +const utils_1 = require("./utils"); +const svgReg = /"']|"[^"]*"|'[^']*')*>/; +const extractorRegExps = { + height: /\sheight=(['"])([^%]+?)\1/, + root: svgReg, + viewbox: /\sviewBox=(['"])(.+?)\1/i, + width: /\swidth=(['"])([^%]+?)\1/, +}; +const INCH_CM = 2.54; +const units = { + in: 96, + cm: 96 / INCH_CM, + em: 16, + ex: 8, + m: (96 / INCH_CM) * 100, + mm: 96 / INCH_CM / 10, + pc: 96 / 72 / 12, + pt: 96 / 72, + px: 1, +}; +const unitsReg = new RegExp(`^([0-9.]+(?:e\\d+)?)(${Object.keys(units).join('|')})?$`); +function parseLength(len) { + const m = unitsReg.exec(len); + if (!m) { + return undefined; + } + return Math.round(Number(m[1]) * (units[m[2]] || 1)); +} +function parseViewbox(viewbox) { + const bounds = viewbox.split(' '); + return { + height: parseLength(bounds[3]), + width: parseLength(bounds[2]), + }; +} +function parseAttributes(root) { + const width = root.match(extractorRegExps.width); + const height = root.match(extractorRegExps.height); + const viewbox = root.match(extractorRegExps.viewbox); + return { + height: height && parseLength(height[2]), + viewbox: viewbox && parseViewbox(viewbox[2]), + width: width && parseLength(width[2]), + }; +} +function calculateByDimensions(attrs) { + return { + height: attrs.height, + width: attrs.width, + }; +} +function calculateByViewbox(attrs, viewbox) { + const ratio = viewbox.width / viewbox.height; + if (attrs.width) { + return { + height: Math.floor(attrs.width / ratio), + width: attrs.width, + }; + } + if (attrs.height) { + return { + height: attrs.height, + width: Math.floor(attrs.height * ratio), + }; + } + return { + height: viewbox.height, + width: viewbox.width, + }; +} +exports.SVG = { + // Scan only the first kilo-byte to speed up the check on larger files + validate: (input) => svgReg.test((0, utils_1.toUTF8String)(input, 0, 1000)), + calculate(input) { + const root = (0, utils_1.toUTF8String)(input).match(extractorRegExps.root); + if (root) { + const attrs = parseAttributes(root[0]); + if (attrs.width && attrs.height) { + return calculateByDimensions(attrs); + } + if (attrs.viewbox) { + return calculateByViewbox(attrs, attrs.viewbox); + } + } + throw new TypeError('Invalid SVG'); + }, +}; diff --git a/vendor/image-size/dist/types/tga.d.ts b/vendor/image-size/dist/types/tga.d.ts new file mode 100644 index 0000000000..449496362e --- /dev/null +++ b/vendor/image-size/dist/types/tga.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const TGA: IImage; diff --git a/vendor/image-size/dist/types/tga.js b/vendor/image-size/dist/types/tga.js new file mode 100644 index 0000000000..ea371dc37d --- /dev/null +++ b/vendor/image-size/dist/types/tga.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TGA = void 0; +const utils_1 = require("./utils"); +exports.TGA = { + validate(input) { + return (0, utils_1.readUInt16LE)(input, 0) === 0 && (0, utils_1.readUInt16LE)(input, 4) === 0; + }, + calculate(input) { + return { + height: (0, utils_1.readUInt16LE)(input, 14), + width: (0, utils_1.readUInt16LE)(input, 12), + }; + }, +}; diff --git a/vendor/image-size/dist/types/tiff.d.ts b/vendor/image-size/dist/types/tiff.d.ts new file mode 100644 index 0000000000..4d6ecbc6a7 --- /dev/null +++ b/vendor/image-size/dist/types/tiff.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const TIFF: IImage; diff --git a/vendor/image-size/dist/types/tiff.js b/vendor/image-size/dist/types/tiff.js new file mode 100644 index 0000000000..cf1564cbe6 --- /dev/null +++ b/vendor/image-size/dist/types/tiff.js @@ -0,0 +1,95 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TIFF = void 0; +// based on http://www.compix.com/fileformattif.htm +// TO-DO: support big-endian as well +const fs = require("fs"); +const utils_1 = require("./utils"); +// Read IFD (image-file-directory) into a buffer +function readIFD(input, filepath, isBigEndian) { + const ifdOffset = (0, utils_1.readUInt)(input, 32, 4, isBigEndian); + // read only till the end of the file + let bufferSize = 1024; + const fileSize = fs.statSync(filepath).size; + if (ifdOffset + bufferSize > fileSize) { + bufferSize = fileSize - ifdOffset - 10; + } + // populate the buffer + const endBuffer = new Uint8Array(bufferSize); + const descriptor = fs.openSync(filepath, 'r'); + fs.readSync(descriptor, endBuffer, 0, bufferSize, ifdOffset); + fs.closeSync(descriptor); + return endBuffer.slice(2); +} +// TIFF values seem to be messed up on Big-Endian, this helps +function readValue(input, isBigEndian) { + const low = (0, utils_1.readUInt)(input, 16, 8, isBigEndian); + const high = (0, utils_1.readUInt)(input, 16, 10, isBigEndian); + return (high << 16) + low; +} +// move to the next tag +function nextTag(input) { + if (input.length > 24) { + return input.slice(12); + } +} +// Extract IFD tags from TIFF metadata +function extractTags(input, isBigEndian) { + const tags = {}; + let temp = input; + while (temp && temp.length) { + const code = (0, utils_1.readUInt)(temp, 16, 0, isBigEndian); + const type = (0, utils_1.readUInt)(temp, 16, 2, isBigEndian); + const length = (0, utils_1.readUInt)(temp, 32, 4, isBigEndian); + // 0 means end of IFD + if (code === 0) { + break; + } + else { + // 256 is width, 257 is height + // if (code === 256 || code === 257) { + if (length === 1 && (type === 3 || type === 4)) { + tags[code] = readValue(temp, isBigEndian); + } + // move to the next tag + temp = nextTag(temp); + } + } + return tags; +} +// Test if the TIFF is Big Endian or Little Endian +function determineEndianness(input) { + const signature = (0, utils_1.toUTF8String)(input, 0, 2); + if ('II' === signature) { + return 'LE'; + } + else if ('MM' === signature) { + return 'BE'; + } +} +const signatures = [ + // '492049', // currently not supported + '49492a00', // Little endian + '4d4d002a', // Big Endian + // '4d4d002a', // BigTIFF > 4GB. currently not supported +]; +exports.TIFF = { + validate: (input) => signatures.includes((0, utils_1.toHexString)(input, 0, 4)), + calculate(input, filepath) { + if (!filepath) { + throw new TypeError("Tiff doesn't support buffer"); + } + // Determine BE/LE + const isBigEndian = determineEndianness(input) === 'BE'; + // read the IFD + const ifdBuffer = readIFD(input, filepath, isBigEndian); + // extract the tags from the IFD + const tags = extractTags(ifdBuffer, isBigEndian); + const width = tags[256]; + const height = tags[257]; + if (!width || !height) { + throw new TypeError('Invalid Tiff. Missing tags'); + } + return { height, width }; + }, +}; diff --git a/vendor/image-size/dist/types/utils.d.ts b/vendor/image-size/dist/types/utils.d.ts new file mode 100644 index 0000000000..7c79ca8271 --- /dev/null +++ b/vendor/image-size/dist/types/utils.d.ts @@ -0,0 +1,15 @@ +export declare const toUTF8String: (input: Uint8Array, start?: number, end?: number) => string; +export declare const toHexString: (input: Uint8Array, start?: number, end?: number) => string; +export declare const readInt16LE: (input: Uint8Array, offset?: number) => number; +export declare const readUInt16BE: (input: Uint8Array, offset?: number) => number; +export declare const readUInt16LE: (input: Uint8Array, offset?: number) => number; +export declare const readUInt24LE: (input: Uint8Array, offset?: number) => number; +export declare const readInt32LE: (input: Uint8Array, offset?: number) => number; +export declare const readUInt32BE: (input: Uint8Array, offset?: number) => number; +export declare const readUInt32LE: (input: Uint8Array, offset?: number) => number; +export declare function readUInt(input: Uint8Array, bits: 16 | 32, offset: number, isBigEndian: boolean): number; +export declare function findBox(input: Uint8Array, boxName: string, offset: number): { + name: string; + offset: number; + size: number; +} | undefined; diff --git a/vendor/image-size/dist/types/utils.js b/vendor/image-size/dist/types/utils.js new file mode 100644 index 0000000000..3486cd870d --- /dev/null +++ b/vendor/image-size/dist/types/utils.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.findBox = exports.readUInt = exports.readUInt32LE = exports.readUInt32BE = exports.readInt32LE = exports.readUInt24LE = exports.readUInt16LE = exports.readUInt16BE = exports.readInt16LE = exports.toHexString = exports.toUTF8String = void 0; +const decoder = new TextDecoder(); +const toUTF8String = (input, start = 0, end = input.length) => decoder.decode(input.slice(start, end)); +exports.toUTF8String = toUTF8String; +const toHexString = (input, start = 0, end = input.length) => input + .slice(start, end) + .reduce((memo, i) => memo + ('0' + i.toString(16)).slice(-2), ''); +exports.toHexString = toHexString; +const readInt16LE = (input, offset = 0) => { + const val = input[offset] + input[offset + 1] * 2 ** 8; + return val | ((val & (2 ** 15)) * 0x1fffe); +}; +exports.readInt16LE = readInt16LE; +const readUInt16BE = (input, offset = 0) => input[offset] * 2 ** 8 + input[offset + 1]; +exports.readUInt16BE = readUInt16BE; +const readUInt16LE = (input, offset = 0) => input[offset] + input[offset + 1] * 2 ** 8; +exports.readUInt16LE = readUInt16LE; +const readUInt24LE = (input, offset = 0) => input[offset] + input[offset + 1] * 2 ** 8 + input[offset + 2] * 2 ** 16; +exports.readUInt24LE = readUInt24LE; +const readInt32LE = (input, offset = 0) => input[offset] + + input[offset + 1] * 2 ** 8 + + input[offset + 2] * 2 ** 16 + + (input[offset + 3] << 24); +exports.readInt32LE = readInt32LE; +const readUInt32BE = (input, offset = 0) => input[offset] * 2 ** 24 + + input[offset + 1] * 2 ** 16 + + input[offset + 2] * 2 ** 8 + + input[offset + 3]; +exports.readUInt32BE = readUInt32BE; +const readUInt32LE = (input, offset = 0) => input[offset] + + input[offset + 1] * 2 ** 8 + + input[offset + 2] * 2 ** 16 + + input[offset + 3] * 2 ** 24; +exports.readUInt32LE = readUInt32LE; +// Abstract reading multi-byte unsigned integers +const methods = { + readUInt16BE: exports.readUInt16BE, + readUInt16LE: exports.readUInt16LE, + readUInt32BE: exports.readUInt32BE, + readUInt32LE: exports.readUInt32LE, +}; +function readUInt(input, bits, offset, isBigEndian) { + offset = offset || 0; + const endian = isBigEndian ? 'BE' : 'LE'; + const methodName = ('readUInt' + bits + endian); + return methods[methodName](input, offset); +} +exports.readUInt = readUInt; +function readBox(input, offset) { + if (input.length - offset < 8) + return; + const boxSize = (0, exports.readUInt32BE)(input, offset); + if (boxSize < 8) + return; + if (input.length - offset < boxSize) + return; + return { + name: (0, exports.toUTF8String)(input, 4 + offset, 8 + offset), + offset, + size: boxSize, + }; +} +function findBox(input, boxName, offset) { + while (offset < input.length) { + const box = readBox(input, offset); + if (!box) + break; + if (box.name === boxName) + return box; + offset += box.size; + } +} +exports.findBox = findBox; diff --git a/vendor/image-size/dist/types/webp.d.ts b/vendor/image-size/dist/types/webp.d.ts new file mode 100644 index 0000000000..5012ead534 --- /dev/null +++ b/vendor/image-size/dist/types/webp.d.ts @@ -0,0 +1,2 @@ +import type { IImage } from './interface'; +export declare const WEBP: IImage; diff --git a/vendor/image-size/dist/types/webp.js b/vendor/image-size/dist/types/webp.js new file mode 100644 index 0000000000..d1186e17ba --- /dev/null +++ b/vendor/image-size/dist/types/webp.js @@ -0,0 +1,60 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WEBP = void 0; +const utils_1 = require("./utils"); +function calculateExtended(input) { + return { + height: 1 + (0, utils_1.readUInt24LE)(input, 7), + width: 1 + (0, utils_1.readUInt24LE)(input, 4), + }; +} +function calculateLossless(input) { + return { + height: 1 + + (((input[4] & 0xf) << 10) | (input[3] << 2) | ((input[2] & 0xc0) >> 6)), + width: 1 + (((input[2] & 0x3f) << 8) | input[1]), + }; +} +function calculateLossy(input) { + // `& 0x3fff` returns the last 14 bits + // TO-DO: include webp scaling in the calculations + return { + height: (0, utils_1.readInt16LE)(input, 8) & 0x3fff, + width: (0, utils_1.readInt16LE)(input, 6) & 0x3fff, + }; +} +exports.WEBP = { + validate(input) { + const riffHeader = 'RIFF' === (0, utils_1.toUTF8String)(input, 0, 4); + const webpHeader = 'WEBP' === (0, utils_1.toUTF8String)(input, 8, 12); + const vp8Header = 'VP8' === (0, utils_1.toUTF8String)(input, 12, 15); + return riffHeader && webpHeader && vp8Header; + }, + calculate(input) { + const chunkHeader = (0, utils_1.toUTF8String)(input, 12, 16); + input = input.slice(20, 30); + // Extended webp stream signature + if (chunkHeader === 'VP8X') { + const extendedHeader = input[0]; + const validStart = (extendedHeader & 0xc0) === 0; + const validEnd = (extendedHeader & 0x01) === 0; + if (validStart && validEnd) { + return calculateExtended(input); + } + else { + // TODO: breaking change + throw new TypeError('Invalid WebP'); + } + } + // Lossless webp stream signature + if (chunkHeader === 'VP8 ' && input[0] !== 0x2f) { + return calculateLossy(input); + } + // Lossy webp stream signature + const signature = (0, utils_1.toHexString)(input, 3, 6); + if (chunkHeader === 'VP8L' && signature !== '9d012a') { + return calculateLossless(input); + } + throw new TypeError('Invalid WebP'); + }, +}; diff --git a/vendor/image-size/dist/utils/bit-reader.d.ts b/vendor/image-size/dist/utils/bit-reader.d.ts new file mode 100644 index 0000000000..4e59226ecd --- /dev/null +++ b/vendor/image-size/dist/utils/bit-reader.d.ts @@ -0,0 +1,10 @@ +/** This class helps read Uint8Array bit-by-bit */ +export declare class BitReader { + private readonly input; + private readonly endianness; + private byteOffset; + private bitOffset; + constructor(input: Uint8Array, endianness: 'big-endian' | 'little-endian'); + /** Reads a specified number of bits, and move the offset */ + getBits(length?: number): number; +} diff --git a/vendor/image-size/dist/utils/bit-reader.js b/vendor/image-size/dist/utils/bit-reader.js new file mode 100644 index 0000000000..3546348eae --- /dev/null +++ b/vendor/image-size/dist/utils/bit-reader.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BitReader = void 0; +/** This class helps read Uint8Array bit-by-bit */ +class BitReader { + constructor(input, endianness) { + this.input = input; + this.endianness = endianness; + // Skip the first 16 bits (2 bytes) of signature + this.byteOffset = 2; + this.bitOffset = 0; + } + /** Reads a specified number of bits, and move the offset */ + getBits(length = 1) { + let result = 0; + let bitsRead = 0; + while (bitsRead < length) { + if (this.byteOffset >= this.input.length) { + throw new Error('Reached end of input'); + } + const currentByte = this.input[this.byteOffset]; + const bitsLeft = 8 - this.bitOffset; + const bitsToRead = Math.min(length - bitsRead, bitsLeft); + if (this.endianness === 'little-endian') { + const mask = (1 << bitsToRead) - 1; + const bits = (currentByte >> this.bitOffset) & mask; + result |= bits << bitsRead; + } + else { + const mask = ((1 << bitsToRead) - 1) << (8 - this.bitOffset - bitsToRead); + const bits = (currentByte & mask) >> (8 - this.bitOffset - bitsToRead); + result = (result << bitsToRead) | bits; + } + bitsRead += bitsToRead; + this.bitOffset += bitsToRead; + if (this.bitOffset === 8) { + this.byteOffset++; + this.bitOffset = 0; + } + } + return result; + } +} +exports.BitReader = BitReader; diff --git a/vendor/image-size/package.json b/vendor/image-size/package.json new file mode 100644 index 0000000000..ecfdd02028 --- /dev/null +++ b/vendor/image-size/package.json @@ -0,0 +1,71 @@ +{ + "name": "image-size", + "version": "2.0.3", + "description": "get dimensions of any image file", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "bin/image-size.js" + ], + "engines": { + "node": ">=16.x" + }, + "bin": "bin/image-size.js", + "keywords": [ + "image", + "size", + "dimensions", + "resolution", + "width", + "height", + "avif", + "bmp", + "cur", + "gif", + "heic", + "heif", + "icns", + "ico", + "jpeg", + "jxl", + "png", + "psd", + "svg", + "tga", + "tiff", + "webp" + ], + "repository": "git://github.com/image-size/image-size.git", + "author": "netroy (http://netroy.in/)", + "license": "MIT", + "devDependencies": { + "@eslint/js": "9.5.0", + "@types/chai": "4.3.16", + "@types/eslint__js": "8.42.3", + "@types/glob": "8.1.0", + "@types/mocha": "10.0.7", + "@types/node": "18.19.39", + "@types/sinon": "17.0.3", + "chai": "4.4.1", + "eslint": "8.57.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-prettier": "5.1.3", + "glob": "10.4.2", + "mocha": "10.2.0", + "nyc": "15.1.0", + "prettier": "3.3.2", + "sinon": "17.0.1", + "ts-node": "10.9.2", + "typedoc": "0.25.13", + "typescript": "5.4.5", + "typescript-eslint": "7.13.1" + }, + "nyc": { + "include": "lib", + "exclude": "specs/*.spec.ts" + }, + "dependencies": { + "queue": "6.0.2" + } +} From ca1893cd19e3c575956f1a1fdb8116fa8af9c125 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 17:20:19 -0700 Subject: [PATCH 31/46] Remove duplicate processing migration --- drizzle/0072_processing_stage_event_latest_lookup.sql | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 drizzle/0072_processing_stage_event_latest_lookup.sql diff --git a/drizzle/0072_processing_stage_event_latest_lookup.sql b/drizzle/0072_processing_stage_event_latest_lookup.sql deleted file mode 100644 index 6f3723b77c..0000000000 --- a/drizzle/0072_processing_stage_event_latest_lookup.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Keep latest-event lookups ordered by their DISTINCT ON keys so scoped --- processing history does not sort every event row for each operation. -CREATE INDEX processing_stage_event_latest_idx -- noqa: PG01 -ON fitness.processing_stage_event USING btree ( - operation_id, - stage, - dataset_key, - output_path, - model_name, - sequence DESC NULLS LAST -); From 8bf4e947af6ab3d2b144f28049d93be23fc30373 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Mon, 10 Aug 2026 18:23:21 -0700 Subject: [PATCH 32/46] Fix CI regressions from merge resolution --- .../src/repositories/cycling-analytics-repository.test.ts | 5 ++++- src/providers/apple-health/db-insertion.ts | 5 ----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/server/src/repositories/cycling-analytics-repository.test.ts b/packages/server/src/repositories/cycling-analytics-repository.test.ts index 88a698f97f..fcaf326021 100644 --- a/packages/server/src/repositories/cycling-analytics-repository.test.ts +++ b/packages/server/src/repositories/cycling-analytics-repository.test.ts @@ -931,7 +931,10 @@ describe("CyclingAnalyticsRepository", () => { }); it("treats an empty modality from the read model as unknown", async () => { - const sensorStore = makeMockSensorStore([cyclingActivityRow({ modality: "" })]); + const sensorStore = makeMockSensorStore(); + vi.mocked(sensorStore.query).mockImplementation(async (schema) => [ + schema.parse(cyclingActivityRow({ modality: "" })), + ]); const repository = new CyclingAnalyticsRepository( { execute: vi.fn().mockResolvedValue([]) }, "11111111-1111-4111-8111-111111111111", diff --git a/src/providers/apple-health/db-insertion.ts b/src/providers/apple-health/db-insertion.ts index 263fffdee1..2d7d026645 100644 --- a/src/providers/apple-health/db-insertion.ts +++ b/src/providers/apple-health/db-insertion.ts @@ -742,11 +742,6 @@ export async function upsertWorkoutBatch( return results; }); - for (const { activityId, workout } of activityResults) { - if (!workout.hangTen) continue; - await replaceHangTenIntervals(db, activityId, workout); - } - // Batch all GPS route locations across all workouts const allGpsRows: MetricStreamSourceRow[] = []; for (const { activityId, workout } of activityResults) { From 2351ae6db04cdff2bfcb0c38b5828cfc0de86720 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 06:46:35 -0700 Subject: [PATCH 33/46] docs: specify climbing grade preferences --- ...026-08-11-climbing-grade-systems-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-climbing-grade-systems-design.md diff --git a/docs/superpowers/specs/2026-08-11-climbing-grade-systems-design.md b/docs/superpowers/specs/2026-08-11-climbing-grade-systems-design.md new file mode 100644 index 0000000000..2d5897a534 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-climbing-grade-systems-design.md @@ -0,0 +1,85 @@ +# Climbing grade-system preference + +## Goal + +Let a user choose the climbing-grade system used for bouldering and routes, +just as they choose metric or imperial display units. The choice applies to +manual logging and to every climbing display on web and mobile. + +## Scope + +The preference is separate by climbing discipline: + +| Discipline | Available display and input systems | +| --- | --- | +| Boulder | V Scale, Fontainebleau | +| Route | Yosemite Decimal System, French, UIAA, Ewbank, Saxon, Norwegian, Brazilian Crux | + +Aid and ice systems are out of scope because Dofek currently models only +`boulder` and `route` climb types. + +## Architecture + +Use `@openbeta/sandbag` version `0.0.55` as the single source for grade +validation, conversion, grade lists, and score ordering. It is MIT licensed, +supports the systems above, and only allows conversions within a discipline. +Sandbag's package documentation describes the supported systems and its +cross-scale conversion behavior: . + +The server owns grade conversion. Raw climbs retain the grade and grade system +that were supplied at logging or ingest time; no normalized score or converted +grade is stored. Server responses add the selected display grade required by +the clients and retain the original value/system for provenance. This follows +the repository rule that clients render server-computed values and avoids a +second source of conversion logic. + +Expand the accepted source grade-system values from the current V Scale/YDS +pair to the in-scope systems. Replace existing custom grade parsing and sorting +with Sandbag-backed validation and score ordering so there is one canonical +implementation. + +## Preferences and data flow + +Store two independently persisted account preferences through the existing +settings mechanism: + +- boulder display grade system; +- route display grade system. + +The settings screen on web and mobile exposes two accessible selectors. Each +selector includes only systems compatible with its discipline and updates +optimistically, rolling back and displaying the server error if saving fails. + +The manual climbing logger reads the current preference for the selected climb +type, presents that system's valid grades, and saves the chosen grade with that +system as its raw source. The server validates it through Sandbag before +writing. + +All climbing APIs that serve activity detail, progressions, volume, session +summaries, and mobile training data resolve the requesting user's display +preference and return converted display grades. Existing records remain in +their original system. If a historical value cannot be converted, retain and +display its recorded value/system instead of dropping or silently changing it. + +## Error behavior + +Invalid or incompatible grades submitted from a manual log fail with a +specific actionable message naming the grade and selected system. Conversions +never cross disciplines. Failed preference reads/writes use the existing +telemetry and user-visible error patterns on both clients. + +## Verification + +Write tests first, then implement. Coverage includes: + +- Sandbag-backed validation, conversion, and ordering for every in-scope + system; +- rejected invalid grades and cross-discipline conversions; +- persisted independent route and boulder preferences; +- server responses for all climbing views using the chosen display system while + preserving source provenance; +- web and mobile settings selectors, logger grade choices, and converted + displayed grades. + +Run focused unit tests while developing, followed by the affected workspace +test suites, lint, and typecheck before handoff. From 56896d57e8075f83f0a05088f4db5bcca6c3151a Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 08:53:04 -0700 Subject: [PATCH 34/46] docs: plan climbing grade systems --- .../2026-08-11-climbing-grade-systems.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-climbing-grade-systems.md diff --git a/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md new file mode 100644 index 0000000000..60c67bd4f9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md @@ -0,0 +1,315 @@ +# Climbing Grade Systems Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add independently persisted boulder and route grade-system preferences, powered by Sandbag, to manual logging and all climbing displays. + +**Architecture:** `@dofek/training` owns the Sandbag adapter and its domain types. The server validates and converts raw grades at its API boundary, while Postgres retains only the recorded grade and source system. Web and mobile only render server-provided display grades and use the shared adapter's grade option lists for manual input. + +**Tech Stack:** TypeScript, pnpm workspace, `@openbeta/sandbag@0.0.55`, Zod, Drizzle/Postgres, tRPC, React, React Native, Vitest. + +## Global Constraints + +- Use `@openbeta/sandbag@0.0.55` as the single parser, validator, converter, grade-list source, and score-ordering implementation. +- Support bouldering (`v_scale`, `font`) and routes (`yds`, `french`, `uiaa`, `ewbank`, `saxon`, `norwegian`, `brazilian_crux`) only. +- Store the source grade and source system; never persist a converted grade or computed score. +- Compute display grades and sorting on the server; clients render the returned display fields. +- Implement parity on both `packages/web` and `packages/mobile`. +- Follow TDD: each behavior test must fail for the missing behavior before its implementation is written. + +--- + +### Task 1: Replace bespoke parsing with the Sandbag-backed grade domain + +**Files:** +- Modify: `packages/training/package.json` +- Modify: `pnpm-lock.yaml` +- Modify: `packages/training/src/climbing-grades.ts` +- Modify: `packages/training/src/climbing-grades.test.ts` +- Modify: `packages/training/README.md` + +**Interfaces:** +- Produces `ClimbingGradeSystem`, `ClimbingGradePreference`, `DEFAULT_CLIMBING_GRADE_PREFERENCE`, `gradeSystemsForClimbType`, `gradeOptionsForSystem`, `isValidClimbingGrade`, `convertClimbingGrade`, and `gradeSortValue` from `@dofek/training/climbing-grades`. +- `convertClimbingGrade({ grade, sourceSystem, displaySystem })` returns `{ displayGrade, displaySystem, sortValue }` or `null` for an invalid/cross-discipline grade. + +- [ ] **Step 1: Write the failing domain tests.** + +```ts +expect(gradeSystemsForClimbType("boulder")).toEqual(["v_scale", "font"]); +expect(gradeOptionsForSystem("font")).toContain("6A"); +expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "font" })) + .toMatchObject({ displaySystem: "font", displayGrade: "6b+" }); +expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "yds" })) + .toBeNull(); +``` + +- [ ] **Step 2: Verify the tests fail.** + +Run: `pnpm exec vitest run --project unit packages/training/src/climbing-grades.test.ts` + +Expected: FAIL because the new Sandbag adapter exports do not exist. + +- [ ] **Step 3: Install the reviewed dependency and implement the adapter.** + +Run: `pnpm --filter @dofek/training add @openbeta/sandbag@0.0.55` + +```ts +export const CLIMBING_GRADE_SYSTEMS = [ + "v_scale", "font", "yds", "french", "uiaa", "ewbank", "saxon", "norwegian", "brazilian_crux", +] as const; + +export const DEFAULT_CLIMBING_GRADE_PREFERENCE = { boulder: "v_scale", route: "yds" } as const; +``` + +Map these stable Dofek names to Sandbag `GradeScales`, reject a target outside the source scale's conversion group, and average Sandbag score ranges for `sortValue`. Remove the regex parser rather than retaining a parallel conversion table. + +- [ ] **Step 4: Verify the domain tests pass and update the package README public API table.** + +Run: `pnpm exec vitest run --project unit packages/training/src/climbing-grades.test.ts && pnpm --filter @dofek/training typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit the isolated domain change.** + +```bash +git add packages/training/package.json packages/training/src/climbing-grades.ts packages/training/src/climbing-grades.test.ts packages/training/README.md pnpm-lock.yaml +git commit -m "feat(training): use sandbag for climbing grades" +``` + +### Task 2: Persist valid source systems and make climbing APIs preference-aware + +**Files:** +- Create: `drizzle/0074_climbing_grade_systems.sql` +- Modify: `drizzle/meta/_journal.json` +- Modify: `src/db/schema/enums.ts` +- Modify: `packages/server/src/routers/settings.ts` +- Create: `packages/server/src/climbing-grade-preferences.ts` +- Create: `packages/server/src/climbing-grade-preferences.test.ts` +- Modify: `packages/server/src/routers/climbing.ts` +- Modify: `packages/server/src/routers/climbing.test.ts` +- Modify: `packages/server/src/routers/climbing.integration.test.ts` +- Modify: `packages/server/src/repositories/climbing-training-log-repository.ts` +- Modify: `packages/server/src/repositories/climbing-repository.ts` +- Modify: `packages/server/src/repositories/climbing-repository.test.ts` +- Modify: `packages/server/src/contracts/mobile-dashboard-contracts.ts` +- Modify: `packages/server/src/contracts/mobile-dashboard-contracts.test.ts` +- Modify: `packages/server/src/services/mobile-training-tab.ts` +- Modify: `packages/server/src/services/mobile-training-tab.test.ts` + +**Interfaces:** +- `CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY = "climbingGradeSystems"` stores `{ boulder: BoulderGradeSystem; route: RouteGradeSystem }`. +- Climbing response grades expose `grade`, `gradeSystem`, `sourceGrade`, `sourceGradeSystem`, and `gradeSortValue`; `grade`/`gradeSystem` are display values. +- `ClimbingRepository` accepts a resolved `ClimbingGradePreference` and groups volume by display grade while retaining source-grade provenance. + +- [ ] **Step 1: Write failing preference, router, and database integration tests.** + +```ts +expect(resolveClimbingGradePreference(null)).toEqual(DEFAULT_CLIMBING_GRADE_PREFERENCE); +await caller.settings.set({ key: "climbingGradeSystems", value: { boulder: "font", route: "french" } }); +await expect(caller.climbing.logClimbingSession(fontSession)).resolves.toBeDefined(); +await expect(caller.climbing.logClimbingSession({ ...fontSession, climbs: [{ ...fontClimb, grade: "V4" }] })) + .rejects.toMatchObject({ message: expect.stringContaining("Fontainebleau") }); +``` + +The integration fixture inserts a `font` and a `french` grade into a real `fitness.climbing_entry`, proves Postgres accepts the extended enum, then queries the route with the opposite display preferences and asserts source provenance is retained. + +- [ ] **Step 2: Verify the new tests fail.** + +Run: `pnpm exec vitest run --project unit packages/server/src/climbing-grade-preferences.test.ts packages/server/src/routers/climbing.test.ts && pnpm test:integration -- packages/server/src/routers/climbing.integration.test.ts` + +Expected: FAIL because the setting key, enum members, and converted fields do not exist. + +- [ ] **Step 3: Add the enum migration and setting contract.** + +```sql +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'font'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'french'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'uiaa'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'ewbank'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'saxon'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'norwegian'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'brazilian_crux'; +``` + +Add the same values to Drizzle's enum, add a strict Zod setting union member, and resolve malformed/missing saved values to `DEFAULT_CLIMBING_GRADE_PREFERENCE` without writing a replacement. + +- [ ] **Step 4: Validate logs and project API rows through the shared domain.** + +```ts +.superRefine((climb, ctx) => { + if (!isGradeSystemForClimbType(climb.gradeSystem, climb.climbType)) ctx.addIssue(...); + if (!isValidClimbingGrade(climb.grade, climb.gradeSystem)) ctx.addIssue(...); +}); +``` + +Load the preference once per request before constructing `ClimbingRepository`. Replace SQL's V/YDS `CASE` sorter with raw-row queries plus Sandbag score ordering in repository code. Return display fields and source fields from progression, volume, session-summary, and activity-entry results. Update mobile dashboard contracts and load the same preference in `loadMobileTrainingTab` before invoking the repository. + +- [ ] **Step 5: Verify server tests pass.** + +Run: `pnpm exec vitest run --project unit packages/server/src/climbing-grade-preferences.test.ts packages/server/src/repositories/climbing-repository.test.ts packages/server/src/routers/climbing.test.ts packages/server/src/services/mobile-training-tab.test.ts packages/server/src/contracts/mobile-dashboard-contracts.test.ts && pnpm test:integration -- packages/server/src/routers/climbing.integration.test.ts` + +Expected: PASS; no static SQL-string assertions are added for database behavior. + +- [ ] **Step 6: Commit the server and schema change.** + +```bash +git add drizzle src/db/schema/enums.ts packages/server/src +git commit -m "feat(climbing): add grade display preferences" +``` + +### Task 3: Add web preference controls and preference-aware manual logging + +**Files:** +- Create: `packages/web/src/components/ClimbingGradeSystemToggle.tsx` +- Create: `packages/web/src/components/ClimbingGradeSystemToggle.test.tsx` +- Create: `packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx` +- Modify: `packages/web/src/pages/SettingsPage.tsx` +- Modify: `packages/web/src/pages/SettingsPage.test.tsx` +- Modify: `packages/web/src/components/ClimbingAttemptLog.tsx` +- Modify: `packages/web/src/components/ClimbingAttemptLog.test.tsx` +- Modify: `packages/web/src/routes/training/climbing.tsx` +- Modify: `packages/web/src/routes/training/climbing.test.tsx` +- Modify: `packages/web/src/pages/activity-detail/components/ClimbingEntryBreakdown.tsx` +- Modify: `packages/web/src/components/ClimbingGradeProgressionChart.tsx` +- Modify: `packages/web/src/components/ClimbingVolumeByGradeChart.tsx` + +**Interfaces:** +- `ClimbingGradeSystemToggle` receives the resolved preference and `onChange(preference)`; it is presentation-only. +- `ClimbingAttemptLog` receives `gradePreference` and submits the matching selected `gradeSystem`. + +- [ ] **Step 1: Write failing component and route tests.** + +```tsx +render(); +fireEvent.change(screen.getByLabelText("Boulder grade system"), { target: { value: "font" } }); +expect(onChange).toHaveBeenCalledWith({ boulder: "font", route: "yds" }); + +render(); +expect(screen.getByRole("option", { name: "6A" })).toBeInTheDocument(); +``` + +- [ ] **Step 2: Verify the tests fail.** + +Run: `pnpm exec vitest run --project unit packages/web/src/components/ClimbingGradeSystemToggle.test.tsx packages/web/src/components/ClimbingAttemptLog.test.tsx packages/web/src/pages/SettingsPage.test.tsx packages/web/src/routes/training/climbing.test.tsx` + +Expected: FAIL because the selector and preference props do not exist. + +- [ ] **Step 3: Implement the web controls and render server display values.** + +```tsx + +``` + +Fetch and save `climbingGradeSystems` with the same optimistic-cache rollback/error behavior as `UnitProvider`. Pass the preference into the climbing logger. Replace free-text grade entry with the shared system's valid grade options. Render `grade`/`gradeSystem` returned by the server in chart labels and detail badges; do not convert grades in the browser. + +- [ ] **Step 4: Verify web tests and build typecheck pass.** + +Run: `pnpm exec vitest run --project unit packages/web/src/components/ClimbingGradeSystemToggle.test.tsx packages/web/src/components/ClimbingAttemptLog.test.tsx packages/web/src/pages/SettingsPage.test.tsx packages/web/src/routes/training/climbing.test.tsx && pnpm --dir packages/web typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit the web change.** + +```bash +git add packages/web/src +git commit -m "feat(web): select climbing grade systems" +``` + +### Task 4: Add mobile preference controls and preference-aware manual logging + +**Files:** +- Modify: `packages/mobile/app/settings.tsx` +- Modify: `packages/mobile/app-tests/settings.test.tsx` +- Modify: `packages/mobile/app/climbing-log.tsx` +- Modify: `packages/mobile/components/ClimbingAttemptLog.tsx` +- Modify: `packages/mobile/components/ClimbingAttemptLog.test.tsx` +- Modify: `packages/mobile/app-tests/(tabs)/strain.test.tsx` +- Modify: `packages/mobile/app-tests/activity/[id].test.tsx` + +**Interfaces:** +- The mobile settings screen reads/writes `climbingGradeSystems` and rolls back its cached setting after a failed mutation. +- The mobile logger receives `gradePreference`, offers its valid grades by selected climb type, and submits the corresponding source system. + +- [ ] **Step 1: Write failing screen and logger tests.** + +```tsx +fireEvent.press(screen.getByLabelText("Boulder grade system Fontainebleau")); +expect(setSettingMutation).toHaveBeenCalledWith({ + key: "climbingGradeSystems", + value: { boulder: "font", route: "yds" }, +}); + +render(); +fireEvent.press(screen.getByLabelText("Grade 6A")); +fireEvent.press(screen.getByLabelText("Save climbing session")); +expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + climbs: [expect.objectContaining({ grade: "6A", gradeSystem: "font" })], +})); +``` + +- [ ] **Step 2: Verify the tests fail.** + +Run: `pnpm exec vitest run --project unit packages/mobile/components/ClimbingAttemptLog.test.tsx packages/mobile/app-tests/settings.test.tsx packages/mobile/app-tests/\(tabs\)/strain.test.tsx packages/mobile/app-tests/activity/\[id\].test.tsx` + +Expected: FAIL because the mobile preference state and grade-option picker do not exist. + +- [ ] **Step 3: Implement mobile selectors and renderer parity.** + +```tsx + ({ value, label: gradeSystemLabel(value) }))} + selected={preference.boulder} + onSelect={(boulder) => savePreference({ ...preference, boulder })} +/> +``` + +Place both controls in Goals & Models alongside Units. Add the grade option picker to `ClimbingAttemptLog`, reset the selected grade when climb type or its preference changes, and pass the fetched preference from `climbing-log.tsx`. Use the server-provided display `grade` fields in strain and activity details; do not add conversion logic to mobile. + +- [ ] **Step 4: Verify mobile tests and typecheck pass.** + +Run: `pnpm exec vitest run --project unit packages/mobile/components/ClimbingAttemptLog.test.tsx packages/mobile/app-tests/settings.test.tsx packages/mobile/app-tests/\(tabs\)/strain.test.tsx packages/mobile/app-tests/activity/\[id\].test.tsx && pnpm --dir packages/mobile typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit the mobile change.** + +```bash +git add packages/mobile +git commit -m "feat(mobile): select climbing grade systems" +``` + +### Task 5: Whole-feature verification and handoff + +**Files:** +- Modify only files required by failed lint/type/test checks from Tasks 1–4. + +- [ ] **Step 1: Run full affected unit suite.** + +Run: `pnpm test:changed` + +Expected: PASS. + +- [ ] **Step 2: Run database-backed feature validation.** + +Run: `pnpm test:integration -- packages/server/src/routers/climbing.integration.test.ts` + +Expected: PASS with the current workspace Compose dependencies. + +- [ ] **Step 3: Run static checks.** + +Run: `pnpm lint && pnpm typecheck` + +Expected: PASS. + +- [ ] **Step 4: Inspect the final change and commit any verification fixes.** + +Run: `git diff --check && git status --short` + +Expected: no whitespace errors and no unrelated files staged. + +- [ ] **Step 5: Push the completed branch.** + +Run: `git push` From 2cde960aea31b0802e03c35b6aed50b8260390f0 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 09:20:13 -0700 Subject: [PATCH 35/46] feat(training): use sandbag for climbing grades --- packages/training/README.md | 2 +- packages/training/package.json | 3 +- packages/training/src/climbing-grades.test.ts | 102 ++++---- packages/training/src/climbing-grades.ts | 245 +++++++++++------- packages/training/src/openbeta-sandbag.d.ts | 30 +++ pnpm-lock.yaml | 23 +- 6 files changed, 258 insertions(+), 147 deletions(-) create mode 100644 packages/training/src/openbeta-sandbag.d.ts diff --git a/packages/training/README.md b/packages/training/README.md index d723a5c4de..a872cb2037 100644 --- a/packages/training/README.md +++ b/packages/training/README.md @@ -49,7 +49,7 @@ Every public module is imported as `@dofek/training/`. | Subpath | Purpose | | --- | --- | | `activity-icons` | Normalize activity names into framework-neutral icon categories | -| `climbing-grades` | Parse V-scale and Yosemite Decimal System climbing grades | +| `climbing-grades` | Validate, order, and convert Sandbag-supported boulder and route climbing grades | | `derived-cardio` | Cycling and submaximal walking/running VO2 max estimates and validation | | `endurance-types` | Endurance and indoor-cycling type guards | | `grade-adjusted-pace` | Grade cost factor and adjusted running pace | diff --git a/packages/training/package.json b/packages/training/package.json index 5db7252b1a..7f446fb898 100644 --- a/packages/training/package.json +++ b/packages/training/package.json @@ -43,7 +43,8 @@ }, "dependencies": { "@dofek/scoring": "workspace:*", - "@dofek/zones": "workspace:*" + "@dofek/zones": "workspace:*", + "@openbeta/sandbag": "0.0.55" }, "scripts": { "build": "tsc", diff --git a/packages/training/src/climbing-grades.test.ts b/packages/training/src/climbing-grades.test.ts index 3782c43f1e..c11ef28519 100644 --- a/packages/training/src/climbing-grades.test.ts +++ b/packages/training/src/climbing-grades.test.ts @@ -1,45 +1,65 @@ import { describe, expect, it } from "vitest"; -import { parseClimbingGrade } from "./climbing-grades.ts"; +import { + convertClimbingGrade, + gradeOptionsForSystem, + gradeSystemsForClimbType, + isGradeSystemForClimbType, + isValidClimbingGrade, + parseClimbingGrade, +} from "./climbing-grades.ts"; describe("parseClimbingGrade", () => { - it("normalizes V-scale grades and returns bouldering sort values", () => { - expect(parseClimbingGrade("VB")).toEqual({ - gradeSystem: "v_scale", - grade: "VB", - sortValue: -1, - }); - expect(parseClimbingGrade("V0")).toEqual({ - gradeSystem: "v_scale", - grade: "V0", - sortValue: 0, - }); - expect(parseClimbingGrade("V1")).toEqual({ - gradeSystem: "v_scale", - grade: "V1", - sortValue: 1, - }); - expect(parseClimbingGrade("V5")).toEqual({ - gradeSystem: "v_scale", - grade: "V5", - sortValue: 5, - }); - expect(parseClimbingGrade("V10")).toEqual({ - gradeSystem: "v_scale", - grade: "V10", - sortValue: 10, - }); + it("uses Sandbag's compatible systems and conversions", () => { + expect(gradeSystemsForClimbType("boulder")).toEqual(["v_scale", "font"]); + expect(gradeSystemsForClimbType("route")).toEqual([ + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", + ]); + expect(gradeOptionsForSystem("font")).toContain("6a"); + expect(isGradeSystemForClimbType("font", "boulder")).toBe(true); + expect(isGradeSystemForClimbType("font", "route")).toBe(false); + expect(isValidClimbingGrade("6a", "font")).toBe(true); + expect(isValidClimbingGrade("V4", "font")).toBe(false); + expect( + convertClimbingGrade({ + grade: "V4", + sourceSystem: "v_scale", + displaySystem: "font", + }), + ).toEqual({ displayGrade: "6a+/6b+", displaySystem: "font", sortValue: 65 }); + expect( + convertClimbingGrade({ + grade: "V4", + sourceSystem: "v_scale", + displaySystem: "yds", + }), + ).toBeNull(); + }); + it("normalizes V-scale grades and orders them by Sandbag score", () => { + const parsedGrades = ["VB", "V0", "V1", "V5", "V10"].map(parseClimbingGrade); + + expect(parsedGrades.map((grade) => grade?.grade)).toEqual(["VB", "V0", "V1", "V5", "V10"]); + expect(parsedGrades.every((grade) => grade?.gradeSystem === "v_scale")).toBe(true); + expect(parsedGrades.map((grade) => grade?.sortValue)).toEqual( + [...parsedGrades.map((grade) => grade?.sortValue)].sort((left, right) => Number(left) - Number(right)), + ); }); it("normalizes V-scale case and whitespace", () => { expect(parseClimbingGrade(" v5 ")).toEqual({ gradeSystem: "v_scale", grade: "V5", - sortValue: 5, + sortValue: 69, }); expect(parseClimbingGrade(" vb ")).toEqual({ gradeSystem: "v_scale", grade: "VB", - sortValue: -1, + sortValue: 17.5, }); }); @@ -62,27 +82,17 @@ describe("parseClimbingGrade", () => { expect(sortValues).toEqual([...sortValues].sort((left, right) => Number(left) - Number(right))); }); - it("normalizes Yosemite Decimal System plus and minus variants to deterministic neighboring values", () => { - expect(parseClimbingGrade(" 5.12- ")).toEqual({ + it("normalizes Yosemite Decimal System plus and minus variants", () => { + expect(parseClimbingGrade(" 5.12- ")).toMatchObject({ gradeSystem: "yds", grade: "5.12-", - sortValue: 5117, - }); - expect(parseClimbingGrade("5.12")).toEqual({ - gradeSystem: "yds", - grade: "5.12", - sortValue: 5120, - }); - expect(parseClimbingGrade("5.12+")).toEqual({ - gradeSystem: "yds", - grade: "5.12+", - sortValue: 5125, }); + expect(parseClimbingGrade("5.12")).toMatchObject({ gradeSystem: "yds", grade: "5.12" }); + expect(parseClimbingGrade("5.12+")).toMatchObject({ gradeSystem: "yds", grade: "5.12+" }); }); it("returns null for invalid Yosemite Decimal System labels", () => { expect(parseClimbingGrade("5")).toBeNull(); - expect(parseClimbingGrade("5.16")).toBeNull(); expect(parseClimbingGrade("5.99")).toBeNull(); expect(parseClimbingGrade("5.x")).toBeNull(); expect(parseClimbingGrade("5.10aa")).toBeNull(); @@ -90,14 +100,14 @@ describe("parseClimbingGrade", () => { expect(parseClimbingGrade("6.1")).toBeNull(); }); - it("returns readable display grades with helper-only sort values", () => { + it("returns readable display grades with Sandbag sort values", () => { expect(parseClimbingGrade("v5")).toMatchObject({ grade: "V5", - sortValue: 5, + sortValue: 69, }); expect(parseClimbingGrade("5.10c")).toMatchObject({ grade: "5.10c", - sortValue: 5103, + sortValue: 64.5, }); }); }); diff --git a/packages/training/src/climbing-grades.ts b/packages/training/src/climbing-grades.ts index 0f8a1c6b22..9f823f55ec 100644 --- a/packages/training/src/climbing-grades.ts +++ b/packages/training/src/climbing-grades.ts @@ -1,111 +1,172 @@ -export type ClimbingGradeSystem = "v_scale" | "yds"; +/// + +import { convertGrade, getScale, GradeScales } from "@openbeta/sandbag"; + +export const CLIMBING_GRADE_SYSTEMS = [ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", +] as const; + +export type ClimbingGradeSystem = (typeof CLIMBING_GRADE_SYSTEMS)[number]; +export type BoulderGradeSystem = "v_scale" | "font"; +export type RouteGradeSystem = Exclude; +export type ClimbingClimbType = "boulder" | "route"; + +export interface ClimbingGradePreference { + boulder: BoulderGradeSystem; + route: RouteGradeSystem; +} export interface ParsedClimbingGrade { - gradeSystem: ClimbingGradeSystem; + gradeSystem: "v_scale" | "yds"; grade: string; sortValue: number; } -const vScalePattern = /^V(?B|0|[1-9]\d*)$/i; -const yosemiteDecimalSystemPattern = /^5\.(?\d{1,2})(?[ABCD]|[+-])?$/i; - -const yosemiteLetterSortOffsets = new Map([ - ["a", 1], - ["b", 2], - ["c", 3], - ["d", 4], -]); - -class ClimbingGradeParser { - parse(input: string): ParsedClimbingGrade | null { - const trimmedInput = input.trim(); - - return ( - this.#parseVScaleGrade(trimmedInput) ?? this.#parseYosemiteDecimalSystemGrade(trimmedInput) - ); - } +export interface ConvertedClimbingGrade { + displayGrade: string; + displaySystem: ClimbingGradeSystem; + sortValue: number; +} - #parseVScaleGrade(input: string): ParsedClimbingGrade | null { - const match = vScalePattern.exec(input); - const matchedGrade = match?.groups?.grade; - if (!matchedGrade) { - return null; - } - - if (matchedGrade.toUpperCase() === "B") { - return { - gradeSystem: "v_scale", - grade: "VB", - sortValue: -1, - }; - } - - const sortValue = Number.parseInt(matchedGrade, 10); - if (!Number.isSafeInteger(sortValue)) { - return null; - } - - return { - gradeSystem: "v_scale", - grade: `V${sortValue}`, - sortValue, - }; - } +export const DEFAULT_CLIMBING_GRADE_PREFERENCE: ClimbingGradePreference = { + boulder: "v_scale", + route: "yds", +}; + +const systemToSandbagScale = { + v_scale: GradeScales.VSCALE, + font: GradeScales.FONT, + yds: GradeScales.YDS, + french: GradeScales.FRENCH, + uiaa: GradeScales.UIAA, + ewbank: GradeScales.EWBANK, + saxon: GradeScales.SAXON, + norwegian: GradeScales.NORWEGIAN, + brazilian_crux: GradeScales.BRAZILIAN_CRUX, +} as const satisfies Record; + +const SYSTEM_LABELS: Record = { + v_scale: "V Scale", + font: "Fontainebleau", + yds: "Yosemite Decimal System", + french: "French", + uiaa: "UIAA", + ewbank: "Ewbank", + saxon: "Saxon", + norwegian: "Norwegian", + brazilian_crux: "Brazilian Crux", +}; + +const BOULDER_SYSTEMS: readonly BoulderGradeSystem[] = ["v_scale", "font"]; +const ROUTE_SYSTEMS: readonly RouteGradeSystem[] = [ + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", +]; + +function sandbagScale(system: ClimbingGradeSystem) { + const scale = getScale(systemToSandbagScale[system]); + if (!scale) throw new Error(`Sandbag does not support climbing grade system: ${system}`); + return scale; +} - #parseYosemiteDecimalSystemGrade(input: string): ParsedClimbingGrade | null { - const match = yosemiteDecimalSystemPattern.exec(input); - const matchedMajorGrade = match?.groups?.majorGrade; - if (!matchedMajorGrade) { - return null; - } - - const majorGrade = Number.parseInt(matchedMajorGrade, 10); - if (!Number.isInteger(majorGrade) || majorGrade < 0 || majorGrade > 15) { - return null; - } - - const modifier = match.groups?.modifier?.toLowerCase(); - const sortValue = this.#computeYosemiteDecimalSystemSortValue(majorGrade, modifier); - if (sortValue === null) { - return null; - } - - return { - gradeSystem: "yds", - grade: `5.${majorGrade}${modifier ?? ""}`, - sortValue, - }; - } +function canonicalGrade(grade: string, system: ClimbingGradeSystem): string | null { + const trimmed = grade.trim(); + const scale = sandbagScale(system); + if (!scale.isType(trimmed)) return null; + const normalized = trimmed.toLocaleLowerCase(); + const listedGrade = scale.grades.find((candidate) => candidate.toLocaleLowerCase() === normalized); + if (listedGrade) return listedGrade; + if (system !== "yds") return null; + const yosemiteBase = /^5\.(\d+)([+-])?$/i.exec(trimmed); + if (!yosemiteBase) return null; + const major = yosemiteBase[1]; + const hasKnownSubdivision = scale.grades.some((candidate) => + new RegExp(`^5\\.${major}[abcd]$`, "i").test(candidate), + ); + return hasKnownSubdivision ? `5.${major}${yosemiteBase[2] ?? ""}` : null; +} - #computeYosemiteDecimalSystemSortValue( - majorGrade: number, - modifier: string | undefined, - ): number | null { - const baseSortValue = 5000 + majorGrade * 10; +export function gradeSystemsForClimbType(climbType: "boulder"): readonly BoulderGradeSystem[]; +export function gradeSystemsForClimbType(climbType: "route"): readonly RouteGradeSystem[]; +export function gradeSystemsForClimbType( + climbType: ClimbingClimbType, +): readonly ClimbingGradeSystem[]; +export function gradeSystemsForClimbType( + climbType: ClimbingClimbType, +): readonly ClimbingGradeSystem[] { + return climbType === "boulder" ? BOULDER_SYSTEMS : ROUTE_SYSTEMS; +} - if (!modifier) { - return baseSortValue; - } +export function gradeOptionsForSystem(system: ClimbingGradeSystem): readonly string[] { + return sandbagScale(system).grades; +} - if (modifier === "-") { - return baseSortValue - 3; - } +export function gradeSystemLabel(system: ClimbingGradeSystem): string { + return SYSTEM_LABELS[system]; +} - if (modifier === "+") { - return baseSortValue + 5; - } +export function isGradeSystemForClimbType( + system: ClimbingGradeSystem, + climbType: ClimbingClimbType, +): boolean { + return gradeSystemsForClimbType(climbType).includes(system as never); +} - const letterSortOffset = yosemiteLetterSortOffsets.get(modifier); - if (letterSortOffset === undefined) { - return null; - } +export function isValidClimbingGrade(grade: string, system: ClimbingGradeSystem): boolean { + return canonicalGrade(grade, system) !== null; +} - return baseSortValue + letterSortOffset; - } +export function gradeSortValue(grade: string, system: ClimbingGradeSystem): number | null { + const canonical = canonicalGrade(grade, system); + if (!canonical) return null; + const score = sandbagScale(system).getScore(canonical); + return typeof score === "number" ? score : (score[0] + score[1]) / 2; } -const climbingGradeParser = new ClimbingGradeParser(); +export function convertClimbingGrade(input: { + grade: string; + sourceSystem: ClimbingGradeSystem; + displaySystem: ClimbingGradeSystem; +}): ConvertedClimbingGrade | null { + const sourceGrade = canonicalGrade(input.grade, input.sourceSystem); + if (!sourceGrade) return null; + const sourceScale = sandbagScale(input.sourceSystem); + const displayScale = sandbagScale(input.displaySystem); + if (sourceScale.conversionGroup !== displayScale.conversionGroup) return null; + const sortValue = gradeSortValue(sourceGrade, input.sourceSystem); + if (sortValue === null) return null; + return { + displayGrade: + input.sourceSystem === input.displaySystem + ? sourceGrade + : convertGrade( + sourceGrade, + systemToSandbagScale[input.sourceSystem], + systemToSandbagScale[input.displaySystem], + ), + displaySystem: input.displaySystem, + sortValue, + }; +} export function parseClimbingGrade(input: string): ParsedClimbingGrade | null { - return climbingGradeParser.parse(input); + for (const gradeSystem of ["v_scale", "yds"] as const) { + const grade = canonicalGrade(input, gradeSystem); + const sortValue = grade ? gradeSortValue(grade, gradeSystem) : null; + if (grade && sortValue !== null) return { gradeSystem, grade, sortValue }; + } + return null; } diff --git a/packages/training/src/openbeta-sandbag.d.ts b/packages/training/src/openbeta-sandbag.d.ts new file mode 100644 index 0000000000..5ae32539c4 --- /dev/null +++ b/packages/training/src/openbeta-sandbag.d.ts @@ -0,0 +1,30 @@ +declare module "@openbeta/sandbag" { + export interface SandbagGradeScale { + conversionGroup: string; + getScore(grade: string): number | [number, number]; + grades: string[]; + isType(grade: string): boolean; + } + + export const GradeScales: { + readonly VSCALE: "vscale"; + readonly FONT: "font"; + readonly YDS: "yds"; + readonly FRENCH: "french"; + readonly UIAA: "uiaa"; + readonly EWBANK: "ewbank"; + readonly SAXON: "saxon"; + readonly NORWEGIAN: "norwegian"; + readonly BRAZILIAN_CRUX: "brazilian_crux"; + }; + + export function convertGrade( + fromGrade: string, + fromScale: (typeof GradeScales)[keyof typeof GradeScales], + toScale: (typeof GradeScales)[keyof typeof GradeScales], + ): string; + + export function getScale( + scale: (typeof GradeScales)[keyof typeof GradeScales], + ): SandbagGradeScale | null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9346cac05e..e16c46b860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -838,6 +838,9 @@ importers: '@dofek/zones': specifier: workspace:* version: link:../zones + '@openbeta/sandbag': + specifier: 0.0.55 + version: 0.0.55 packages/trainingpeaks-connect: dependencies: @@ -3978,6 +3981,10 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openbeta/sandbag@0.0.55': + resolution: {integrity: sha512-mHRK0uY/DpwstuTws6rJ89XwqJxkGKuJo9SWnLWqoWo51ITaXAHkPn4dwa1y3K4OJ+yoBl5SQuETB4JvbzgZhg==} + engines: {node: '>=14'} + '@opentelemetry/api-logs@0.207.0': resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} engines: {node: '>=8.0.0'} @@ -17236,7 +17243,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -17322,7 +17329,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@11.0.0) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -17538,7 +17545,7 @@ snapshots: '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)(supports-color@11.0.0) - '@babel/traverse': 7.29.7(supports-color@11.0.0) + '@babel/traverse': 7.29.8(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -20162,6 +20169,8 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openbeta/sandbag@0.0.55': {} + '@opentelemetry/api-logs@0.207.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -21738,7 +21747,7 @@ snapshots: '@react-native/codegen@0.86.2(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 hermes-parser: 0.36.0 invariant: 2.2.4 nullthrows: 1.1.1 @@ -30589,8 +30598,8 @@ snapshots: metro-source-map@0.84.4(supports-color@11.0.0): dependencies: - '@babel/traverse': 7.29.7(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@11.0.0) + '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 metro-symbolicate: 0.84.4(supports-color@11.0.0) @@ -32353,7 +32362,7 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)(supports-color@11.0.0) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@react-native/metro-config': 0.86.2(@babel/core@7.29.7)(supports-color@11.0.0) convert-source-map: 2.0.0 react: 19.2.3 From d7744d199dc93ed4d350216ec35ccd25289aee4e Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 11:27:31 -0700 Subject: [PATCH 36/46] feat(climbing): add grade system preferences --- docs/schema.dbml | 60 +- docs/schema.puml | 17 +- drizzle/0074_climbing_grade_systems.sql | 7 + drizzle/meta/_journal.json | 7 + packages/mobile/app/climbing-log.tsx | 27 + packages/mobile/app/settings.tsx | 116 ++++ .../components/ClimbingAttemptLog.test.tsx | 23 + .../mobile/components/ClimbingAttemptLog.tsx | 20 +- .../src/climbing-grade-preferences.test.ts | 26 + .../server/src/climbing-grade-preferences.ts | 37 ++ .../contracts/mobile-dashboard-contracts.ts | 24 +- .../repositories/climbing-repository.test.ts | 68 +-- .../src/repositories/climbing-repository.ts | 528 +++++++++--------- .../climbing-training-log-repository.ts | 3 +- packages/server/src/routers/climbing.test.ts | 63 ++- packages/server/src/routers/climbing.ts | 91 ++- packages/server/src/routers/settings.ts | 8 + .../src/services/mobile-training-tab.ts | 13 +- packages/training/src/climbing-grades.test.ts | 4 +- packages/training/src/climbing-grades.ts | 10 +- .../components/ClimbingAttemptLog.test.tsx | 21 + .../web/src/components/ClimbingAttemptLog.tsx | 35 +- .../components/ClimbingGradeSystemToggle.tsx | 129 +++++ packages/web/src/pages/SettingsPage.tsx | 10 + .../web/src/routes/training/climbing.test.tsx | 4 + packages/web/src/routes/training/climbing.tsx | 27 + src/db/drizzle-schema.test.ts | 12 +- src/db/schema/enums.ts | 12 +- 28 files changed, 1012 insertions(+), 390 deletions(-) create mode 100644 drizzle/0074_climbing_grade_systems.sql create mode 100644 packages/server/src/climbing-grade-preferences.test.ts create mode 100644 packages/server/src/climbing-grade-preferences.ts create mode 100644 packages/web/src/components/ClimbingGradeSystemToggle.tsx diff --git a/docs/schema.dbml b/docs/schema.dbml index adc82db4a1..9c735a0620 100644 --- a/docs/schema.dbml +++ b/docs/schema.dbml @@ -137,7 +137,14 @@ enum climbing_failure_reason { enum climbing_grade_system { v_scale + font yds + french + uiaa + ewbank + saxon + norwegian + brazilian_crux } enum climbing_hold_type { @@ -463,6 +470,7 @@ table fitness.activity { (user_id, provider_id, external_id) [name: 'activity_provider_external_idx', unique] (user_id, provider_id) [name: 'activity_user_provider_idx'] + } } @@ -520,6 +528,14 @@ table fitness.climbing_entry { activity_id [name: 'climbing_entry_activity_idx'] (climb_type, grade_system, grade) [name: 'climbing_entry_grade_lookup_idx'] (activity_id, external_id) [name: 'climbing_entry_activity_external_id_idx', unique] + + + + + + + + } } @@ -1311,13 +1327,13 @@ table fitness.body_region { parent_id text label text [not null] kind text [not null] - sort_order integer [not null, default: 0] + sort_order bigint [not null, default: 0] indexes { (parent_id, sort_order, id) [name: 'body_region_parent_sort_idx'] - - - + + + } } @@ -1488,17 +1504,18 @@ table fitness.injury_event { body_region_id text [not null] onset_date date [not null] resolved_date date - severity integer [not null] + severity bigint description text [not null] created_at "timestamp with time zone" [not null, default: `now()`] updated_at "timestamp with time zone" [not null, default: `now()`] indexes { (user_id, onset_date) [name: 'injury_event_user_onset_idx'] - - - - + body_region_id [name: 'injury_event_body_region_idx'] + + + + } } @@ -1670,13 +1687,13 @@ table fitness.subjective_symptom { check_in_id uuid [not null] body_region_id text [not null] kind text [not null] - score integer [not null] + score bigint [not null] indexes { (check_in_id, body_region_id, kind) [name: 'subjective_symptom_unique_kind', unique] body_region_id [name: 'subjective_symptom_region_idx'] - - + + } } @@ -1700,6 +1717,18 @@ table fitness.sync_log { } } +table fitness.processing_alert_dismissal { + user_id uuid [not null] + operation_id uuid [not null] + dismissed_at "timestamp with time zone" [not null, default: `now()`] + + indexes { + (user_id, operation_id) [pk] + + + } +} + table fitness.processing_flow_marker { id uuid [pk, not null, default: `gen_random_uuid()`] operation_id uuid [not null] @@ -1805,6 +1834,7 @@ table fitness.processing_stage_event { (operation_id, stage, dataset_key, output_path, model_name, status, idempotency_key) [name: 'processing_stage_event_idempotency_key', unique] (operation_id, sequence) [name: 'processing_stage_event_operation_sequence_idx'] + (operation_id, stage, dataset_key, output_path, model_name, sequence) [name: 'processing_stage_event_latest_idx'] (dataset_key, sequence) [name: 'processing_stage_event_dataset_sequence_idx'] @@ -1970,6 +2000,8 @@ ref user_password_credential_user_id_user_profile_id_fk: fitness.user_password_c ref user_settings_user_id_user_profile_id_fk: fitness.user_settings.user_id > fitness.user_profile.id [delete: no action, update: no action] +ref body_region_parent_id_body_region_id_fk: fitness.body_region.parent_id > fitness.body_region.id [delete: restrict, update: no action] + ref breathwork_session_user_id_user_profile_id_fk: fitness.breathwork_session.user_id > fitness.user_profile.id [delete: no action, update: no action] ref dexa_scan_provider_id_provider_id_fk: fitness.dexa_scan.provider_id > fitness.provider.id [delete: no action, update: no action] @@ -2020,6 +2052,10 @@ ref sync_log_provider_id_provider_id_fk: fitness.sync_log.provider_id > fitness. ref sync_log_user_id_user_profile_id_fk: fitness.sync_log.user_id > fitness.user_profile.id [delete: no action, update: no action] +ref processing_alert_dismissal_user_fk: fitness.processing_alert_dismissal.user_id > fitness.user_profile.id [delete: cascade, update: no action] + +ref processing_alert_dismissal_operation_fk: fitness.processing_alert_dismissal.operation_id > fitness.processing_operation.id [delete: cascade, update: no action] + ref processing_flow_marker_operation_fk: fitness.processing_flow_marker.operation_id > fitness.processing_operation.id [delete: cascade, update: no action] ref processing_metric_batch_operation_fk: fitness.processing_metric_stream_batch.operation_id > fitness.processing_operation.id [delete: cascade, update: no action] diff --git a/docs/schema.puml b/docs/schema.puml index 8d0dc9c847..b03eb8efba 100644 --- a/docs/schema.puml +++ b/docs/schema.puml @@ -816,10 +816,10 @@ entity "user_settings" { entity "body_region" { * id : text <> -- - parent_id : text + parent_id : text <> label : text kind : text - sort_order : integer + sort_order : bigint } entity "breathwork_session" { @@ -949,7 +949,7 @@ entity "injury_event" { body_region_id : text <> onset_date : date resolved_date : date - severity : integer + severity : bigint description : text created_at : timestamp updated_at : timestamp @@ -1077,7 +1077,7 @@ entity "subjective_symptom" { check_in_id : uuid <> body_region_id : text <> kind : text - score : integer + score : bigint } entity "sync_log" { @@ -1095,6 +1095,12 @@ entity "sync_log" { synced_at : timestamp } +entity "processing_alert_dismissal" { + user_id : uuid <> + operation_id : uuid <> + dismissed_at : timestamp +} + entity "processing_flow_marker" { * id : uuid <> -- @@ -1239,6 +1245,7 @@ user_profile ||--o{ user_billing user_profile ||--o{ user_external_effect user_profile ||--o{ user_password_credential user_profile ||--o{ user_settings +body_region ||--o{ body_region user_profile ||--o{ breathwork_session provider ||--o{ dexa_scan user_profile ||--o{ dexa_scan @@ -1264,6 +1271,8 @@ subjective_check_in ||--o{ subjective_symptom body_region ||--o{ subjective_symptom provider ||--o{ sync_log user_profile ||--o{ sync_log +user_profile ||--o{ processing_alert_dismissal +processing_operation ||--o{ processing_alert_dismissal processing_operation ||--o{ processing_flow_marker processing_operation ||--o{ processing_metric_stream_batch user_profile ||--o{ processing_operation diff --git a/drizzle/0074_climbing_grade_systems.sql b/drizzle/0074_climbing_grade_systems.sql new file mode 100644 index 0000000000..3534d89593 --- /dev/null +++ b/drizzle/0074_climbing_grade_systems.sql @@ -0,0 +1,7 @@ +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'font'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'french'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'uiaa'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'ewbank'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'saxon'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'norwegian'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'brazilian_crux'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7120ae4150..970c471c8c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1786380000000, "tag": "0073_processing_stage_event_latest_lookup", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1786466044000, + "tag": "0074_climbing_grade_systems", + "breakpoints": true } ] } diff --git a/packages/mobile/app/climbing-log.tsx b/packages/mobile/app/climbing-log.tsx index 4d1a0db3cc..0e2d8b48de 100644 --- a/packages/mobile/app/climbing-log.tsx +++ b/packages/mobile/app/climbing-log.tsx @@ -1,3 +1,7 @@ +import { + type ClimbingGradePreference, + DEFAULT_CLIMBING_GRADE_PREFERENCE, +} from "@dofek/training/climbing-grades"; import { useEffect, useRef } from "react"; import { ScrollView, StyleSheet, Text, View } from "react-native"; import { @@ -15,7 +19,9 @@ export default function ClimbingLogScreen() { const units = useUnitConverter(); const utils = trpc.useUtils(); const history = trpc.climbing.fingerLoadingHistory.useQuery({ days: 90 }); + const gradePreferenceSetting = trpc.settings.get.useQuery({ key: "climbingGradeSystems" }); const lastHistoryError = useRef(null); + const gradePreference = resolveGradePreference(gradePreferenceSetting.data?.value); useEffect(() => { if (history.error && lastHistoryError.current !== history.error) { @@ -76,6 +82,7 @@ export default function ClimbingLogScreen() { climbingMutation.mutate(input)} submitting={climbingMutation.isPending} /> @@ -84,6 +91,26 @@ export default function ClimbingLogScreen() { ); } +function resolveGradePreference(value: unknown): ClimbingGradePreference { + if ( + typeof value === "object" && + value !== null && + "boulder" in value && + "route" in value && + (value.boulder === "v_scale" || value.boulder === "font") && + (value.route === "yds" || + value.route === "french" || + value.route === "uiaa" || + value.route === "ewbank" || + value.route === "saxon" || + value.route === "norwegian" || + value.route === "brazilian_crux") + ) { + return { boulder: value.boulder, route: value.route }; + } + return DEFAULT_CLIMBING_GRADE_PREFERENCE; +} + const styles = StyleSheet.create({ container: { backgroundColor: colors.background, flex: 1 }, content: { gap: spacing.lg, padding: spacing.md }, diff --git a/packages/mobile/app/settings.tsx b/packages/mobile/app/settings.tsx index 1f00de8acf..4fcadbe16e 100644 --- a/packages/mobile/app/settings.tsx +++ b/packages/mobile/app/settings.tsx @@ -5,6 +5,13 @@ import { PASSWORD_REQUIREMENT_TEXT, } from "@dofek/auth/auth"; import { formatDateMedium, formatDateTime } from "@dofek/format/format"; +import { + type BoulderGradeSystem, + type ClimbingGradePreference, + DEFAULT_CLIMBING_GRADE_PREFERENCE, + gradeSystemLabel, + type RouteGradeSystem, +} from "@dofek/training/climbing-grades"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as Updates from "expo-updates"; import { useEffect, useRef, useState } from "react"; @@ -57,6 +64,16 @@ const UNIT_OPTIONS: { value: UnitSystem; label: string; description: string }[] { value: "metric", label: "Metric", description: "kg, km, °C" }, { value: "imperial", label: "Imperial", description: "lbs, mi, °F" }, ]; +const BOULDER_GRADE_SYSTEMS: BoulderGradeSystem[] = ["v_scale", "font"]; +const ROUTE_GRADE_SYSTEMS: RouteGradeSystem[] = [ + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", +]; const SETTINGS_CATEGORIES: readonly { id: SettingsCategory; label: string; @@ -235,6 +252,7 @@ export default function SettingsScreen() { // ── Unit System ── const unitSetting = trpc.settings.get.useQuery({ key: "unitSystem" }); + const climbingGradeSetting = trpc.settings.get.useQuery({ key: "climbingGradeSystems" }); const setSettingMutation = trpc.settings.set.useMutation(); const lastUnitReadError = useRef(null); const billingStatus = trpc.billing.status.useQuery(); @@ -258,6 +276,7 @@ export default function SettingsScreen() { const currentUnitSystem: UnitSystem = unitSetting.data?.value === "imperial" ? "imperial" : "metric"; + const climbingGradePreference = resolveGradePreference(climbingGradeSetting.data?.value); async function startCheckout(): Promise { setCheckoutClientError(null); @@ -302,6 +321,25 @@ export default function SettingsScreen() { ); } + function handleClimbingGradeChange(next: ClimbingGradePreference) { + const key = "climbingGradeSystems" as const; + const previousSetting = trpcUtils.settings.get.getData({ key }); + trpcUtils.settings.get.setData({ key }, { key, value: next }); + setSettingMutation.mutate( + { key, value: next }, + { + onError: (error) => { + trpcUtils.settings.get.setData({ key }, previousSetting); + captureException(error, { context: "climbing-grade-systems-write" }); + Alert.alert("Error", error.message); + }, + onSettled: () => { + void trpcUtils.settings.get.invalidate({ key }); + }, + }, + ); + } + function handleSetPassword() { if (passwordStatus.data?.hasPassword && !currentPassword) { setPasswordFormError("Enter your current password."); @@ -486,6 +524,64 @@ export default function SettingsScreen() { ) : null} + {activeCategory === "goals-models" ? ( + + Climbing grades + + Choose the grade systems used for boulders and routes + + {climbingGradeSetting.error ? ( + {climbingGradeSetting.error.message} + ) : null} + Boulder grades + + {BOULDER_GRADE_SYSTEMS.map((value) => { + const selected = climbingGradePreference.boulder === value; + return ( + + handleClimbingGradeChange({ ...climbingGradePreference, boulder: value }) + } + disabled={setSettingMutation.isPending} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: setSettingMutation.isPending }} + > + + {gradeSystemLabel(value)} + + + ); + })} + + Route grades + + {ROUTE_GRADE_SYSTEMS.map((value) => { + const selected = climbingGradePreference.route === value; + return ( + + handleClimbingGradeChange({ ...climbingGradePreference, route: value }) + } + disabled={setSettingMutation.isPending} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: setSettingMutation.isPending }} + > + + {gradeSystemLabel(value)} + + + ); + })} + + + ) : null} + {/* ── Health Reports ── */} {activeCategory === "goals-models" ? ( @@ -897,3 +993,23 @@ export default function SettingsScreen() { ); } + +function resolveGradePreference(value: unknown): ClimbingGradePreference { + if ( + typeof value === "object" && + value !== null && + "boulder" in value && + "route" in value && + (value.boulder === "v_scale" || value.boulder === "font") && + (value.route === "yds" || + value.route === "french" || + value.route === "uiaa" || + value.route === "ewbank" || + value.route === "saxon" || + value.route === "norwegian" || + value.route === "brazilian_crux") + ) { + return { boulder: value.boulder, route: value.route }; + } + return DEFAULT_CLIMBING_GRADE_PREFERENCE; +} diff --git a/packages/mobile/components/ClimbingAttemptLog.test.tsx b/packages/mobile/components/ClimbingAttemptLog.test.tsx index be6c36c3e8..65d43e964d 100644 --- a/packages/mobile/components/ClimbingAttemptLog.test.tsx +++ b/packages/mobile/components/ClimbingAttemptLog.test.tsx @@ -31,4 +31,27 @@ describe("mobile ClimbingAttemptLog", () => { }), ); }); + + it("uses the selected grade preference for manual logging", () => { + const onSubmit = vi.fn(); + render( + , + ); + + const gradeInput = screen.getAllByRole("textbox")[0]; + if (!gradeInput) throw new Error("Grade input is required"); + fireEvent.change(gradeInput, { target: { value: "6a" } }); + fireEvent.click(screen.getByLabelText("Save climbing session")); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + climbs: [expect.objectContaining({ grade: "6a", gradeSystem: "font" })], + }), + ); + }); }); diff --git a/packages/mobile/components/ClimbingAttemptLog.tsx b/packages/mobile/components/ClimbingAttemptLog.tsx index 23e3748bd7..ab08e1bc99 100644 --- a/packages/mobile/components/ClimbingAttemptLog.tsx +++ b/packages/mobile/components/ClimbingAttemptLog.tsx @@ -1,3 +1,9 @@ +import { + type ClimbingGradePreference, + type ClimbingGradeSystem, + DEFAULT_CLIMBING_GRADE_PREFERENCE, + gradeSystemLabel, +} from "@dofek/training/climbing-grades"; import { useState } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { colors, radius, spacing } from "../theme"; @@ -34,7 +40,7 @@ export interface ClimbingSessionSubmission { }>; climbType: "boulder" | "route"; grade: string; - gradeSystem: "v_scale" | "yds"; + gradeSystem: ClimbingGradeSystem; holdType: HoldType; routeName: string | null; wallAngleDegrees: number; @@ -46,10 +52,12 @@ export interface ClimbingSessionSubmission { export function ClimbingAttemptLog({ errorMessage, + gradePreference = DEFAULT_CLIMBING_GRADE_PREFERENCE, onSubmit, submitting, }: { errorMessage: string | null; + gradePreference?: ClimbingGradePreference; onSubmit: (input: ClimbingSessionSubmission) => void; submitting: boolean; }) { @@ -71,6 +79,8 @@ export function ClimbingAttemptLog({ ); } + const gradeSystem = gradePreference[climbType]; + return ( @@ -85,7 +95,11 @@ export function ClimbingAttemptLog({ ]} selected={climbType} /> - + @@ -154,7 +168,7 @@ export function ClimbingAttemptLog({ })), climbType, grade, - gradeSystem: climbType === "boulder" ? "v_scale" : "yds", + gradeSystem, holdType, routeName: routeName.trim() || null, wallAngleDegrees: Number(wallAngle), diff --git a/packages/server/src/climbing-grade-preferences.test.ts b/packages/server/src/climbing-grade-preferences.test.ts new file mode 100644 index 0000000000..ef03ab9e7d --- /dev/null +++ b/packages/server/src/climbing-grade-preferences.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY, + resolveClimbingGradePreference, +} from "./climbing-grade-preferences.ts"; + +describe("resolveClimbingGradePreference", () => { + it("returns V Scale and YDS defaults when no valid preference is saved", () => { + expect(resolveClimbingGradePreference(null)).toEqual({ boulder: "v_scale", route: "yds" }); + expect(resolveClimbingGradePreference({ boulder: "yds", route: "font" })).toEqual({ + boulder: "v_scale", + route: "yds", + }); + }); + + it("keeps independent valid boulder and route systems", () => { + expect(resolveClimbingGradePreference({ boulder: "font", route: "french" })).toEqual({ + boulder: "font", + route: "french", + }); + }); + + it("uses the stable account-setting key", () => { + expect(CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY).toBe("climbingGradeSystems"); + }); +}); diff --git a/packages/server/src/climbing-grade-preferences.ts b/packages/server/src/climbing-grade-preferences.ts new file mode 100644 index 0000000000..5d6e19ac3d --- /dev/null +++ b/packages/server/src/climbing-grade-preferences.ts @@ -0,0 +1,37 @@ +import { + type ClimbingGradePreference, + DEFAULT_CLIMBING_GRADE_PREFERENCE, +} from "@dofek/training/climbing-grades"; +import type { Database } from "dofek/db"; +import { sql } from "drizzle-orm"; +import { z } from "zod"; +import { executeWithSchema } from "./lib/typed-sql.ts"; + +export const CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY = "climbingGradeSystems"; + +export const climbingGradePreferenceSchema = z.strictObject({ + boulder: z.enum(["v_scale", "font"]), + route: z.enum(["yds", "french", "uiaa", "ewbank", "saxon", "norwegian", "brazilian_crux"]), +}); + +export function resolveClimbingGradePreference(value: unknown): ClimbingGradePreference { + const parsed = climbingGradePreferenceSchema.safeParse(value); + return parsed.success ? parsed.data : DEFAULT_CLIMBING_GRADE_PREFERENCE; +} + +const climbingGradePreferenceRowSchema = z.object({ value: z.unknown() }); + +export async function loadClimbingGradePreference( + database: Pick, + userId: string, +): Promise { + const rows = await executeWithSchema( + database, + climbingGradePreferenceRowSchema, + sql`SELECT value FROM fitness.user_settings + WHERE user_id = ${userId}::uuid + AND key = ${CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY} + LIMIT 1`, + ); + return resolveClimbingGradePreference(rows[0]?.value); +} diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index 5e3880565e..bfbfe72e4e 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -353,7 +353,17 @@ export const mobileTrainingTabOutputSchema = z.object({ z.object({ date: dateSchema, climbType: z.enum(["boulder", "route"]), - gradeSystem: z.enum(["v_scale", "yds"]), + gradeSystem: z.enum([ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", + ]), grade: z.string(), gradeSortValue: z.number(), }), @@ -361,7 +371,17 @@ export const mobileTrainingTabOutputSchema = z.object({ volumeByGrade: z.array( z.object({ climbType: z.enum(["boulder", "route"]), - gradeSystem: z.enum(["v_scale", "yds"]), + gradeSystem: z.enum([ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", + ]), grade: z.string(), gradeSortValue: z.number(), attempts: z.number().int().nonnegative(), diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index 8c6cacaf72..12e14d9b65 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -170,14 +170,14 @@ describe("ClimbingRepository", () => { climbType: "boulder", gradeSystem: "v_scale", grade: "V3", - gradeSortValue: 3, + gradeSortValue: 60, }, { date: "2026-07-09", climbType: "route", gradeSystem: "yds", grade: "5.10c", - gradeSortValue: 5103, + gradeSortValue: 64.5, }, ]); }); @@ -195,7 +195,6 @@ describe("ClimbingRepository", () => { expect(text).toContain("detail.attempt_count > 0"); expect(text).toContain("BOOL_OR(attempt.outcome = 'sent')"); expect(text).toContain("ELSE ce.sent"); - expect(text).toContain("IS NOT NULL"); expect(text).toContain("NOW() - "); }); @@ -252,7 +251,7 @@ describe("ClimbingRepository", () => { climbType: "boulder", gradeSystem: "v_scale", grade: "V2", - gradeSortValue: 2, + gradeSortValue: 55, attempts: 6, sends: 4, }, @@ -260,7 +259,7 @@ describe("ClimbingRepository", () => { climbType: "route", gradeSystem: "yds", grade: "5.12-", - gradeSortValue: 5117, + gradeSortValue: 75.5, attempts: 2, sends: 1, }, @@ -277,8 +276,7 @@ describe("ClimbingRepository", () => { expect(text).toContain("ELSE ce.attempt_count"); expect(text).toContain("WHEN detail.attempt_count > 0 THEN detail.sent"); expect(text).toContain("ELSE ce.sent"); - expect(text).toContain("GROUP BY ce.climb_type, ce.grade_system, ce.grade, grade_sort_value"); - expect(text).toContain("ORDER BY grade_sort_value"); + expect(text).toContain("GROUP BY ce.climb_type, ce.grade_system, ce.grade"); }); }); @@ -296,24 +294,22 @@ describe("ClimbingRepository", () => { session_date: "2026-07-09", name: "Kaya climbing at Touchstone Pacific Pipe", location_name: "Touchstone Pacific Pipe", - attempts: 12, - sends: 8, - hardest_boulder_grade: "V4", - hardest_boulder_grade_sort_value: 4, - hardest_route_grade: null, - hardest_route_grade_sort_value: null, + attempt_count: 12, + sent: true, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", }, { activity_id: "activity-2", session_date: "2026-07-10", name: "Evening routes", location_name: "Mission Cliffs", - attempts: 5, - sends: 2, - hardest_boulder_grade: null, - hardest_boulder_grade_sort_value: null, - hardest_route_grade: "5.10c", - hardest_route_grade_sort_value: 5103, + attempt_count: 5, + sent: true, + climb_type: "route", + grade_system: "yds", + grade: "5.10c", }, ]); @@ -321,29 +317,29 @@ describe("ClimbingRepository", () => { expect(result[0]).toBeInstanceOf(ClimbingSessionSummary); expect(result.map((row) => row.toDetail())).toEqual([ - { - activityId: "activity-1", - date: "2026-07-09", - name: "Kaya climbing at Touchstone Pacific Pipe", - locationName: "Touchstone Pacific Pipe", - attempts: 12, - sends: 8, - hardestBoulderGrade: "V4", - hardestBoulderGradeSortValue: 4, - hardestRouteGrade: null, - hardestRouteGradeSortValue: null, - }, { activityId: "activity-2", date: "2026-07-10", name: "Evening routes", locationName: "Mission Cliffs", attempts: 5, - sends: 2, + sends: 1, hardestBoulderGrade: null, hardestBoulderGradeSortValue: null, hardestRouteGrade: "5.10c", - hardestRouteGradeSortValue: 5103, + hardestRouteGradeSortValue: 64.5, + }, + { + activityId: "activity-1", + date: "2026-07-09", + name: "Kaya climbing at Touchstone Pacific Pipe", + locationName: "Touchstone Pacific Pipe", + attempts: 12, + sends: 1, + hardestBoulderGrade: "V4", + hardestBoulderGradeSortValue: 65, + hardestRouteGrade: null, + hardestRouteGradeSortValue: null, }, ]); }); @@ -357,10 +353,8 @@ describe("ClimbingRepository", () => { expect(text).toContain("fitness.v_activity"); expect(text).toContain("ce.activity_id = ANY(a.member_activity_ids)"); expect(text).toContain("a.canonical_type = 'climbing'"); - expect(text).toContain("IS NOT NULL"); - expect(text).toContain("SUM(attempt_count) AS attempts"); - expect(text).not.toContain("SUM(attempt_count)::int AS attempts"); - expect(text).toContain("COUNT(*) FILTER (WHERE sent)::int AS sends"); + expect(text).toContain("attempt_count"); + expect(text).toContain("ce.grade_system"); }); }); diff --git a/packages/server/src/repositories/climbing-repository.ts b/packages/server/src/repositories/climbing-repository.ts index 036a73bef6..839d60b83f 100644 --- a/packages/server/src/repositories/climbing-repository.ts +++ b/packages/server/src/repositories/climbing-repository.ts @@ -1,11 +1,18 @@ -import { parseClimbingGrade } from "@dofek/training/climbing-grades"; +import { + type ClimbingClimbType, + type ClimbingGradePreference, + type ClimbingGradeSystem, + convertClimbingGrade, + DEFAULT_CLIMBING_GRADE_PREFERENCE, + gradeSortValue, + isGradeSystemForClimbType, +} from "@dofek/training/climbing-grades"; import { sql } from "drizzle-orm"; import { z } from "zod"; import { BaseRepository } from "../lib/base-repository.ts"; import { dateStringSchema, executeWithSchema } from "../lib/typed-sql.ts"; -export type ClimbingClimbType = "boulder" | "route"; -export type ClimbingGradeSystem = "v_scale" | "yds"; +export type { ClimbingClimbType, ClimbingGradeSystem }; export interface ClimbingGradeProgressionRow { date: string; @@ -22,14 +29,8 @@ export class ClimbingGradeProgression { this.#row = row; } - toDetail() { - return { - date: this.#row.date, - climbType: this.#row.climbType, - gradeSystem: this.#row.gradeSystem, - grade: this.#row.grade, - gradeSortValue: this.#row.gradeSortValue, - }; + toDetail(): ClimbingGradeProgressionRow { + return this.#row; } } @@ -49,15 +50,8 @@ export class ClimbingVolumeByGrade { this.#row = row; } - toDetail() { - return { - climbType: this.#row.climbType, - gradeSystem: this.#row.gradeSystem, - grade: this.#row.grade, - gradeSortValue: this.#row.gradeSortValue, - attempts: this.#row.attempts, - sends: this.#row.sends, - }; + toDetail(): ClimbingVolumeByGradeRow { + return this.#row; } } @@ -81,24 +75,23 @@ export class ClimbingSessionSummary { this.#row = row; } - toDetail() { - return { - activityId: this.#row.activityId, - date: this.#row.date, - name: this.#row.name, - locationName: this.#row.locationName, - attempts: this.#row.attempts, - sends: this.#row.sends, - hardestBoulderGrade: this.#row.hardestBoulderGrade, - hardestBoulderGradeSortValue: this.#row.hardestBoulderGradeSortValue, - hardestRouteGrade: this.#row.hardestRouteGrade, - hardestRouteGradeSortValue: this.#row.hardestRouteGradeSortValue, - }; + toDetail(): ClimbingSessionSummaryRow { + return this.#row; } } const climbTypeSchema = z.enum(["boulder", "route"]); -const gradeSystemSchema = z.enum(["v_scale", "yds"]); +const gradeSystemSchema = z.enum([ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", +]); const ascentTypeSchema = z.enum(["Flash", "Onsight", "Redpoint", "Repeat"]); const attemptOutcomeSchema = z.enum(["sent", "failed"]); const failureReasonSchema = z.enum(["fell", "pumped", "skin", "technique", "fear"]); @@ -110,6 +103,30 @@ const climbingAttemptDetailSchema = z.object({ outcome: attemptOutcomeSchema, }); +const progressionRowSchema = z.object({ + session_date: dateStringSchema, + climb_type: climbTypeSchema, + grade_system: gradeSystemSchema, + grade: z.string(), +}); +const volumeByGradeRowSchema = z.object({ + climb_type: climbTypeSchema, + grade_system: gradeSystemSchema, + grade: z.string(), + attempts: z.coerce.number(), + sends: z.coerce.number(), +}); +const sessionEntryRowSchema = z.object({ + activity_id: z.string(), + session_date: dateStringSchema, + name: z.string(), + location_name: z.string().nullable(), + attempt_count: z.coerce.number(), + sent: z.boolean(), + climb_type: climbTypeSchema, + grade_system: gradeSystemSchema, + grade: z.string(), +}); const activityEntryRowSchema = z.object({ id: z.string(), climb_type: climbTypeSchema, @@ -155,60 +172,26 @@ export class ClimbingActivityEntry { } } -const progressionRowSchema = z.object({ - session_date: dateStringSchema, - climb_type: climbTypeSchema, - grade_system: gradeSystemSchema, - grade: z.string(), - grade_sort_value: z.coerce.number(), -}); - -const volumeByGradeRowSchema = z.object({ - climb_type: climbTypeSchema, - grade_system: gradeSystemSchema, - grade: z.string(), - grade_sort_value: z.coerce.number(), - attempts: z.coerce.number(), - sends: z.coerce.number(), -}); - -const sessionSummaryRowSchema = z.object({ - activity_id: z.string(), - session_date: dateStringSchema, - name: z.string(), - location_name: z.string().nullable(), - attempts: z.coerce.number(), - sends: z.coerce.number(), - hardest_boulder_grade: z.string().nullable(), - hardest_boulder_grade_sort_value: z.coerce.number().nullable(), - hardest_route_grade: z.string().nullable(), - hardest_route_grade_sort_value: z.coerce.number().nullable(), -}); - -const climbingGradeSortSql = sql` - CASE - WHEN ce.grade_system = 'v_scale' AND ce.grade = 'VB' THEN -1 - WHEN ce.grade_system = 'v_scale' AND ce.grade ~ '^V[0-9]+$' THEN substring(ce.grade from 2)::int - WHEN ce.grade_system = 'yds' AND ce.grade ~ '^5\\.[0-9]+[abcd]$' THEN - 5000 - + (substring(ce.grade from '^5\\.([0-9]+)')::int * 10) - + CASE right(ce.grade, 1) - WHEN 'a' THEN 1 - WHEN 'b' THEN 2 - WHEN 'c' THEN 3 - WHEN 'd' THEN 4 - END - WHEN ce.grade_system = 'yds' AND ce.grade ~ '^5\\.[0-9]+-$' THEN - 5000 + (substring(ce.grade from '^5\\.([0-9]+)')::int * 10) - 3 - WHEN ce.grade_system = 'yds' AND ce.grade ~ '^5\\.[0-9]+\\+$' THEN - 5000 + (substring(ce.grade from '^5\\.([0-9]+)')::int * 10) + 5 - WHEN ce.grade_system = 'yds' AND ce.grade ~ '^5\\.[0-9]+$' THEN - 5000 + (substring(ce.grade from '^5\\.([0-9]+)')::int * 10) - ELSE NULL - END -`; +interface DisplayGrade { + grade: string; + gradeSortValue: number; + gradeSystem: ClimbingGradeSystem; +} export class ClimbingRepository extends BaseRepository { + readonly #gradePreference: ClimbingGradePreference; + + constructor( + db: ConstructorParameters[0], + userId: string, + timezone: string, + accessWindow?: ConstructorParameters[3], + gradePreference: ClimbingGradePreference = DEFAULT_CLIMBING_GRADE_PREFERENCE, + ) { + super(db, userId, timezone, accessWindow); + this.#gradePreference = gradePreference; + } + #activityWindowPredicate(days: number) { return sql` a.user_id = ${this.userId} @@ -218,53 +201,66 @@ export class ClimbingRepository extends BaseRepository { `; } + #displayGrade( + climbType: ClimbingClimbType, + sourceSystem: ClimbingGradeSystem, + sourceGrade: string, + ): DisplayGrade | null { + if (!isGradeSystemForClimbType(sourceSystem, climbType)) return null; + const displaySystem = this.#gradePreference[climbType]; + const converted = convertClimbingGrade({ + grade: sourceGrade, + sourceSystem, + displaySystem, + }); + if (converted) { + return { + grade: converted.displayGrade, + gradeSystem: converted.displaySystem, + gradeSortValue: converted.sortValue, + }; + } + const sourceSortValue = gradeSortValue(sourceGrade, sourceSystem); + return sourceSortValue === null + ? null + : { grade: sourceGrade, gradeSystem: sourceSystem, gradeSortValue: sourceSortValue }; + } + async getGradeProgression(days: number): Promise { const rows = await executeWithSchema( this.db, progressionRowSchema, - sql`WITH ranked_sent AS ( - SELECT - (a.started_at AT TIME ZONE ${this.timezone})::date::text AS session_date, - ce.climb_type, - ce.grade_system, - ce.grade, - ${climbingGradeSortSql} AS grade_sort_value, - ROW_NUMBER() OVER ( - PARTITION BY (a.started_at AT TIME ZONE ${this.timezone})::date, ce.climb_type - ORDER BY ${climbingGradeSortSql} DESC NULLS LAST - ) AS grade_rank - FROM fitness.v_activity a - JOIN fitness.climbing_entry ce ON ce.activity_id = ANY(a.member_activity_ids) - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::int AS attempt_count, - BOOL_OR(attempt.outcome = 'sent') AS sent - FROM fitness.climbing_attempt AS attempt - WHERE attempt.climbing_entry_id = ce.id - ) AS detail ON true - WHERE ${this.#activityWindowPredicate(days)} - AND CASE - WHEN detail.attempt_count > 0 THEN detail.sent - ELSE ce.sent - END = true - AND ${climbingGradeSortSql} IS NOT NULL - ) - SELECT session_date, climb_type, grade_system, grade, grade_sort_value - FROM ranked_sent - WHERE grade_rank = 1 - ORDER BY session_date, climb_type`, - ); - - return rows.map( - (row) => - new ClimbingGradeProgression({ - date: row.session_date, - climbType: row.climb_type, - gradeSystem: row.grade_system, - grade: normalizedGrade(row.grade), - gradeSortValue: row.grade_sort_value, - }), + sql`SELECT + (a.started_at AT TIME ZONE ${this.timezone})::date::text AS session_date, + ce.climb_type, + ce.grade_system, + ce.grade + FROM fitness.v_activity AS a + JOIN fitness.climbing_entry AS ce ON ce.activity_id = ANY(a.member_activity_ids) + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS attempt_count, BOOL_OR(attempt.outcome = 'sent') AS sent + FROM fitness.climbing_attempt AS attempt + WHERE attempt.climbing_entry_id = ce.id + ) AS detail ON true + WHERE ${this.#activityWindowPredicate(days)} + AND CASE WHEN detail.attempt_count > 0 THEN detail.sent ELSE ce.sent END = true`, ); + const bestBySession = new Map(); + for (const row of rows) { + const display = this.#displayGrade(row.climb_type, row.grade_system, row.grade); + if (!display) continue; + const key = `${row.session_date}:${row.climb_type}`; + const candidate = { date: row.session_date, climbType: row.climb_type, ...display }; + const current = bestBySession.get(key); + if (!current || candidate.gradeSortValue > current.gradeSortValue) + bestBySession.set(key, candidate); + } + return [...bestBySession.values()] + .sort( + (left, right) => + left.date.localeCompare(right.date) || left.climbType.localeCompare(right.climbType), + ) + .map((row) => new ClimbingGradeProgression(row)); } async getVolumeByGrade(days: number): Promise { @@ -275,115 +271,106 @@ export class ClimbingRepository extends BaseRepository { ce.climb_type, ce.grade_system, ce.grade, - ${climbingGradeSortSql} AS grade_sort_value, - SUM( - CASE - WHEN detail.attempt_count > 0 THEN detail.attempt_count - ELSE ce.attempt_count - END - ) AS attempts, - COUNT(*) FILTER ( - WHERE CASE - WHEN detail.attempt_count > 0 THEN detail.sent - ELSE ce.sent - END - )::int AS sends - FROM fitness.v_activity a - JOIN fitness.climbing_entry ce ON ce.activity_id = ANY(a.member_activity_ids) + SUM(CASE WHEN detail.attempt_count > 0 THEN detail.attempt_count ELSE ce.attempt_count END) AS attempts, + COUNT(*) FILTER (WHERE CASE WHEN detail.attempt_count > 0 THEN detail.sent ELSE ce.sent END)::int AS sends + FROM fitness.v_activity AS a + JOIN fitness.climbing_entry AS ce ON ce.activity_id = ANY(a.member_activity_ids) LEFT JOIN LATERAL ( - SELECT - COUNT(*)::int AS attempt_count, - BOOL_OR(attempt.outcome = 'sent') AS sent + SELECT COUNT(*)::int AS attempt_count, BOOL_OR(attempt.outcome = 'sent') AS sent FROM fitness.climbing_attempt AS attempt WHERE attempt.climbing_entry_id = ce.id ) AS detail ON true WHERE ${this.#activityWindowPredicate(days)} - AND ${climbingGradeSortSql} IS NOT NULL - GROUP BY ce.climb_type, ce.grade_system, ce.grade, grade_sort_value - ORDER BY grade_sort_value`, + GROUP BY ce.climb_type, ce.grade_system, ce.grade`, ); - - return rows.map( - (row) => - new ClimbingVolumeByGrade({ + const byDisplayGrade = new Map(); + for (const row of rows) { + const display = this.#displayGrade(row.climb_type, row.grade_system, row.grade); + if (!display) continue; + const key = `${row.climb_type}:${display.gradeSystem}:${display.grade}`; + const current = byDisplayGrade.get(key); + if (current) { + current.attempts += row.attempts; + current.sends += row.sends; + } else { + byDisplayGrade.set(key, { climbType: row.climb_type, - gradeSystem: row.grade_system, - grade: normalizedGrade(row.grade), - gradeSortValue: row.grade_sort_value, + ...display, attempts: row.attempts, sends: row.sends, - }), - ); + }); + } + } + return [...byDisplayGrade.values()] + .sort((left, right) => left.gradeSortValue - right.gradeSortValue) + .map((row) => new ClimbingVolumeByGrade(row)); } async getSessionSummaries(days: number): Promise { const rows = await executeWithSchema( this.db, - sessionSummaryRowSchema, - sql`WITH climbing_entries AS ( - SELECT - a.id AS activity_id, - (a.started_at AT TIME ZONE ${this.timezone})::date::text AS session_date, - COALESCE(a.name, 'Climbing') AS name, - ce.location_name, - CASE - WHEN detail.attempt_count > 0 THEN detail.attempt_count - ELSE ce.attempt_count - END AS attempt_count, - CASE - WHEN detail.attempt_count > 0 THEN detail.sent - ELSE ce.sent - END AS sent, - ce.climb_type, - ce.grade, - ${climbingGradeSortSql} AS grade_sort_value - FROM fitness.v_activity a - JOIN fitness.climbing_entry ce ON ce.activity_id = ANY(a.member_activity_ids) - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::int AS attempt_count, - BOOL_OR(attempt.outcome = 'sent') AS sent - FROM fitness.climbing_attempt AS attempt - WHERE attempt.climbing_entry_id = ce.id - ) AS detail ON true - WHERE ${this.#activityWindowPredicate(days)} - AND ${climbingGradeSortSql} IS NOT NULL - ) - SELECT - activity_id, - session_date, - name, - MAX(location_name) FILTER (WHERE location_name IS NOT NULL) AS location_name, - SUM(attempt_count) AS attempts, - COUNT(*) FILTER (WHERE sent)::int AS sends, - (ARRAY_AGG(grade ORDER BY grade_sort_value DESC NULLS LAST) - FILTER (WHERE sent AND climb_type = 'boulder'))[1] AS hardest_boulder_grade, - (ARRAY_AGG(grade_sort_value ORDER BY grade_sort_value DESC NULLS LAST) - FILTER (WHERE sent AND climb_type = 'boulder'))[1] AS hardest_boulder_grade_sort_value, - (ARRAY_AGG(grade ORDER BY grade_sort_value DESC NULLS LAST) - FILTER (WHERE sent AND climb_type = 'route'))[1] AS hardest_route_grade, - (ARRAY_AGG(grade_sort_value ORDER BY grade_sort_value DESC NULLS LAST) - FILTER (WHERE sent AND climb_type = 'route'))[1] AS hardest_route_grade_sort_value - FROM climbing_entries - GROUP BY activity_id, session_date, name - ORDER BY session_date DESC`, - ); - - return rows.map( - (row) => - new ClimbingSessionSummary({ - activityId: row.activity_id, - date: row.session_date, - name: row.name, - locationName: row.location_name, - attempts: row.attempts, - sends: row.sends, - hardestBoulderGrade: nullableNormalizedGrade(row.hardest_boulder_grade), - hardestBoulderGradeSortValue: row.hardest_boulder_grade_sort_value, - hardestRouteGrade: nullableNormalizedGrade(row.hardest_route_grade), - hardestRouteGradeSortValue: row.hardest_route_grade_sort_value, - }), + sessionEntryRowSchema, + sql`SELECT + a.id::text AS activity_id, + (a.started_at AT TIME ZONE ${this.timezone})::date::text AS session_date, + COALESCE(a.name, 'Climbing') AS name, + ce.location_name, + CASE WHEN detail.attempt_count > 0 THEN detail.attempt_count ELSE ce.attempt_count END AS attempt_count, + CASE WHEN detail.attempt_count > 0 THEN detail.sent ELSE ce.sent END AS sent, + ce.climb_type, + ce.grade_system, + ce.grade + FROM fitness.v_activity AS a + JOIN fitness.climbing_entry AS ce ON ce.activity_id = ANY(a.member_activity_ids) + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS attempt_count, BOOL_OR(attempt.outcome = 'sent') AS sent + FROM fitness.climbing_attempt AS attempt + WHERE attempt.climbing_entry_id = ce.id + ) AS detail ON true + WHERE ${this.#activityWindowPredicate(days)}`, ); + const summaries = new Map(); + for (const row of rows) { + const existing = summaries.get(row.activity_id) ?? { + activityId: row.activity_id, + date: row.session_date, + name: row.name, + locationName: row.location_name, + attempts: 0, + sends: 0, + hardestBoulderGrade: null, + hardestBoulderGradeSortValue: null, + hardestRouteGrade: null, + hardestRouteGradeSortValue: null, + }; + existing.attempts += row.attempt_count; + if (row.sent) existing.sends += 1; + const display = row.sent + ? this.#displayGrade(row.climb_type, row.grade_system, row.grade) + : null; + if ( + display && + row.climb_type === "boulder" && + (existing.hardestBoulderGradeSortValue === null || + display.gradeSortValue > existing.hardestBoulderGradeSortValue) + ) { + existing.hardestBoulderGrade = display.grade; + existing.hardestBoulderGradeSortValue = display.gradeSortValue; + } + if ( + display && + row.climb_type === "route" && + (existing.hardestRouteGradeSortValue === null || + display.gradeSortValue > existing.hardestRouteGradeSortValue) + ) { + existing.hardestRouteGrade = display.grade; + existing.hardestRouteGradeSortValue = display.gradeSortValue; + } + summaries.set(row.activity_id, existing); + } + return [...summaries.values()] + .sort((left, right) => right.date.localeCompare(left.date)) + .map((row) => new ClimbingSessionSummary(row)); } async getActivityEntries(activityId: string): Promise { @@ -395,14 +382,8 @@ export class ClimbingRepository extends BaseRepository { ce.climb_type, ce.grade_system, ce.grade, - CASE - WHEN detail.attempt_count > 0 THEN detail.sent - ELSE ce.sent - END AS sent, - CASE - WHEN detail.attempt_count > 0 THEN detail.attempt_count - ELSE ce.attempt_count - END AS attempt_count, + CASE WHEN detail.attempt_count > 0 THEN detail.sent ELSE ce.sent END AS sent, + CASE WHEN detail.attempt_count > 0 THEN detail.attempt_count ELSE ce.attempt_count END AS attempt_count, COALESCE(detail.attempts, '[]'::jsonb) AS attempts, ce.raw->>'ascentType' AS ascent_type, ce.hold_type, @@ -410,60 +391,57 @@ export class ClimbingRepository extends BaseRepository { ce.location_name, ce.source_name, ce.wall_angle_degrees - FROM fitness.v_activity a - JOIN fitness.climbing_entry ce ON ce.activity_id = ANY(a.member_activity_ids) + FROM fitness.v_activity AS a + JOIN fitness.climbing_entry AS ce ON ce.activity_id = ANY(a.member_activity_ids) LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS attempt_count, BOOL_OR(attempt.outcome = 'sent') AS sent, - jsonb_agg( - jsonb_build_object( - 'attemptIndex', attempt.attempt_index, - 'failureReason', attempt.failure_reason, - 'notes', attempt.notes, - 'outcome', attempt.outcome - ) - ORDER BY attempt.attempt_index - ) AS attempts + jsonb_agg(jsonb_build_object( + 'attemptIndex', attempt.attempt_index, + 'failureReason', attempt.failure_reason, + 'notes', attempt.notes, + 'outcome', attempt.outcome + ) ORDER BY attempt.attempt_index) AS attempts FROM fitness.climbing_attempt AS attempt WHERE attempt.climbing_entry_id = ce.id ) AS detail ON true WHERE a.user_id = ${this.userId}::uuid AND ${activityId}::uuid = ANY(a.member_activity_ids) - ${this.timestampAccessPredicate(sql`a.started_at`)} - ORDER BY ${climbingGradeSortSql} NULLS LAST, ce.route_name NULLS LAST, ce.id`, - ); - - return rows.map( - (row) => - new ClimbingActivityEntry({ - id: row.id, - climbType: row.climb_type, - gradeSystem: row.grade_system, - grade: normalizedGrade(row.grade), - sent: row.sent, - attemptCount: row.attempt_count, - attempts: row.attempts, - ascentType: row.ascent_type, - holdType: row.hold_type, - routeName: row.route_name, - locationName: row.location_name, - sourceName: row.source_name, - wallAngleDegrees: row.wall_angle_degrees, - }), + ${this.timestampAccessPredicate(sql`a.started_at`)}`, ); + return rows + .map((row) => { + const display = this.#displayGrade(row.climb_type, row.grade_system, row.grade); + return display + ? { row, display } + : { + row, + display: { + grade: row.grade, + gradeSystem: row.grade_system, + gradeSortValue: -Infinity, + }, + }; + }) + .sort((left, right) => right.display.gradeSortValue - left.display.gradeSortValue) + .map( + ({ row, display }) => + new ClimbingActivityEntry({ + id: row.id, + climbType: row.climb_type, + gradeSystem: display.gradeSystem, + grade: display.grade, + sent: row.sent, + attemptCount: row.attempt_count, + attempts: row.attempts, + ascentType: row.ascent_type, + holdType: row.hold_type, + routeName: row.route_name, + locationName: row.location_name, + sourceName: row.source_name, + wallAngleDegrees: row.wall_angle_degrees, + }), + ); } } - -function normalizedGrade(grade: string): string { - const parsedGrade = parseClimbingGrade(grade); - if (!parsedGrade) { - throw new Error(`Unsupported climbing grade: ${grade}`); - } - - return parsedGrade.grade; -} - -function nullableNormalizedGrade(grade: string | null): string | null { - return grade === null ? null : normalizedGrade(grade); -} diff --git a/packages/server/src/repositories/climbing-training-log-repository.ts b/packages/server/src/repositories/climbing-training-log-repository.ts index c3b6f05e3c..302a41b3af 100644 --- a/packages/server/src/repositories/climbing-training-log-repository.ts +++ b/packages/server/src/repositories/climbing-training-log-repository.ts @@ -1,4 +1,5 @@ import { resolveProviderActivityType } from "@dofek/training/activity-types"; +import type { ClimbingGradeSystem } from "@dofek/training/climbing-grades"; import type { Database } from "dofek/db"; import { sql } from "drizzle-orm"; import { z } from "zod"; @@ -61,7 +62,7 @@ export interface ClimbInput { attempts: ClimbingAttemptInput[]; climbType: "boulder" | "route"; grade: string; - gradeSystem: "v_scale" | "yds"; + gradeSystem: ClimbingGradeSystem; holdType: z.infer | null; routeName: string | null; wallAngleDegrees: number | null; diff --git a/packages/server/src/routers/climbing.test.ts b/packages/server/src/routers/climbing.test.ts index efbd7dcb62..26150645e9 100644 --- a/packages/server/src/routers/climbing.test.ts +++ b/packages/server/src/routers/climbing.test.ts @@ -106,13 +106,24 @@ function makeClimbingSessionInput({ climbType = "boulder", endedAt = null, failureReason = null, + grade, gradeSystem = "v_scale", outcome = "sent", }: { climbType?: "boulder" | "route"; endedAt?: string | null; failureReason?: "fell" | null; - gradeSystem?: "v_scale" | "yds"; + grade?: string; + gradeSystem?: + | "v_scale" + | "font" + | "yds" + | "french" + | "uiaa" + | "ewbank" + | "saxon" + | "norwegian" + | "brazilian_crux"; outcome?: "failed" | "sent"; } = {}) { return { @@ -120,7 +131,7 @@ function makeClimbingSessionInput({ { attempts: [{ failureReason, notes: null, outcome }], climbType, - grade: climbType === "boulder" ? "V5" : "5.11a", + grade: grade ?? (climbType === "boulder" ? "V5" : "5.11a"), gradeSystem, holdType: "crimp" as const, routeName: null, @@ -165,7 +176,7 @@ describe("climbingRouter", () => { id: "734b5d3e-df2b-4ee0-888e-55ea539d913a", }); - expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(2); expect(result).toEqual([ { id: "entry-1", @@ -192,20 +203,19 @@ describe("climbingRouter", () => { climb_type: "boulder", grade_system: "v_scale", grade: "V4", - grade_sort_value: 4, }, ]); const result: ClimbingGradeProgressionRow[] = await caller.gradeProgression({ days: 90 }); - expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(2); expect(result).toEqual([ { date: "2026-07-09", climbType: "boulder", gradeSystem: "v_scale", grade: "V4", - gradeSortValue: 4, + gradeSortValue: 65, }, ]); }); @@ -216,7 +226,6 @@ describe("climbingRouter", () => { climb_type: "route", grade_system: "yds", grade: "5.10c", - grade_sort_value: 5103, attempts: 3, sends: 2, }, @@ -224,13 +233,13 @@ describe("climbingRouter", () => { const result: ClimbingVolumeByGradeRow[] = await caller.volumeByGrade({ days: 90 }); - expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(2); expect(result).toEqual([ { climbType: "route", gradeSystem: "yds", grade: "5.10c", - gradeSortValue: 5103, + gradeSortValue: 64.5, attempts: 3, sends: 2, }, @@ -244,18 +253,17 @@ describe("climbingRouter", () => { session_date: "2026-07-09", name: "Kaya climbing at Touchstone Pacific Pipe", location_name: "Touchstone Pacific Pipe", - attempts: 9, - sends: 6, - hardest_boulder_grade: "V4", - hardest_boulder_grade_sort_value: 4, - hardest_route_grade: null, - hardest_route_grade_sort_value: null, + attempt_count: 9, + sent: true, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", }, ]); const result: ClimbingSessionSummaryRow[] = await caller.sessionSummary({ days: 90 }); - expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(2); expect(result).toEqual([ { activityId: "activity-1", @@ -263,9 +271,9 @@ describe("climbingRouter", () => { name: "Kaya climbing at Touchstone Pacific Pipe", locationName: "Touchstone Pacific Pipe", attempts: 9, - sends: 6, + sends: 1, hardestBoulderGrade: "V4", - hardestBoulderGradeSortValue: 4, + hardestBoulderGradeSortValue: 65, hardestRouteGrade: null, hardestRouteGradeSortValue: null, }, @@ -336,7 +344,7 @@ describe("climbingRouter", () => { await expect(caller.gradeProgression({ days: 90 })).resolves.toEqual([]); await expect(caller.volumeByGrade({ days: 90 })).resolves.toEqual([]); await expect(caller.sessionSummary({ days: 90 })).resolves.toEqual([]); - expect(execute).toHaveBeenCalledTimes(3); + expect(execute).toHaveBeenCalledTimes(6); }); it("returns a controlled error when climbing data cannot load", async () => { @@ -628,6 +636,23 @@ describe("climbingRouter", () => { ), ).rejects.toMatchObject>({ code: "INTERNAL_SERVER_ERROR" }); + const validFont = makeMutationCaller(); + await expect( + validFont.caller.logClimbingSession( + makeClimbingSessionInput({ climbType: "boulder", grade: "6a", gradeSystem: "font" }), + ), + ).rejects.toMatchObject>({ code: "INTERNAL_SERVER_ERROR" }); + + const invalidGrade = makeMutationCaller(); + await expect( + invalidGrade.caller.logClimbingSession( + makeClimbingSessionInput({ climbType: "boulder", grade: "V4", gradeSystem: "font" }), + ), + ).rejects.toMatchObject>({ + code: "BAD_REQUEST", + }); + expect(invalidGrade.execute).not.toHaveBeenCalled(); + for (const [climbType, gradeSystem] of [ ["boulder", "yds"], ["route", "v_scale"], diff --git a/packages/server/src/routers/climbing.ts b/packages/server/src/routers/climbing.ts index 9f7b56cbe8..acd023afc3 100644 --- a/packages/server/src/routers/climbing.ts +++ b/packages/server/src/routers/climbing.ts @@ -1,7 +1,14 @@ +import { + CLIMBING_GRADE_SYSTEMS, + gradeSystemLabel, + isGradeSystemForClimbType, + isValidClimbingGrade, +} from "@dofek/training/climbing-grades"; import { TRPCError } from "@trpc/server"; import { invalidateAllUserQueries } from "dofek/lib/cache"; import { captureException } from "dofek/lib/error-reporting"; import { z } from "zod"; +import { loadClimbingGradePreference } from "../climbing-grade-preferences.ts"; import { type ClimbingActivityEntryRow, type ClimbingGradeProgressionRow, @@ -73,20 +80,28 @@ const climbingSessionInputSchema = z attempts: z.array(climbingAttemptInputSchema).min(1).max(100), climbType: z.enum(["boulder", "route"]), grade: z.string().trim().min(1).max(20), - gradeSystem: z.enum(["v_scale", "yds"]), + gradeSystem: z.enum(CLIMBING_GRADE_SYSTEMS), holdType: climbingHoldTypeSchema.nullable().default(null), routeName: z.string().trim().min(1).max(200).nullable().default(null), wallAngleDegrees: z.number().min(-90).max(90).nullable().default(null), }) - .refine( - (climb) => - (climb.climbType === "boulder" && climb.gradeSystem === "v_scale") || - (climb.climbType === "route" && climb.gradeSystem === "yds"), - { - message: "Grade system must match the climb type", - path: ["gradeSystem"], - }, - ), + .superRefine((climb, context) => { + if (!isGradeSystemForClimbType(climb.gradeSystem, climb.climbType)) { + context.addIssue({ + code: "custom", + message: "Grade system must match the climb type", + path: ["gradeSystem"], + }); + return; + } + if (!isValidClimbingGrade(climb.grade, climb.gradeSystem)) { + context.addIssue({ + code: "custom", + message: `"${climb.grade}" is not a valid ${gradeSystemLabel(climb.gradeSystem)} grade`, + path: ["grade"], + }); + } + }), ) .min(1) .max(30), @@ -190,33 +205,65 @@ export const climbingRouter = router({ activityEntries: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) .input(z.object({ id: z.guid() })) .query(async ({ ctx, input }): Promise => { - const repository = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); - const rows = await runClimbingQuery(() => repository.getActivityEntries(input.id)); - return rows.map((row) => row.toDetail()); + return runClimbingQuery(async () => { + const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); + const repository = new ClimbingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + preference, + ); + return (await repository.getActivityEntries(input.id)).map((row) => row.toDetail()); + }); }), gradeProgression: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { - const repository = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); - const rows = await runClimbingQuery(() => repository.getGradeProgression(input.days)); - return rows.map((row) => row.toDetail()); + return runClimbingQuery(async () => { + const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); + const repository = new ClimbingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + preference, + ); + return (await repository.getGradeProgression(input.days)).map((row) => row.toDetail()); + }); }), volumeByGrade: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { - const repository = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); - const rows = await runClimbingQuery(() => repository.getVolumeByGrade(input.days)); - return rows.map((row) => row.toDetail()); + return runClimbingQuery(async () => { + const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); + const repository = new ClimbingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + preference, + ); + return (await repository.getVolumeByGrade(input.days)).map((row) => row.toDetail()); + }); }), sessionSummary: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { - const repository = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); - const rows = await runClimbingQuery(() => repository.getSessionSummaries(input.days)); - return rows.map((row) => row.toDetail()); + return runClimbingQuery(async () => { + const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); + const repository = new ClimbingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + preference, + ); + return (await repository.getSessionSummaries(input.days)).map((row) => row.toDetail()); + }); }), hangboardingSummary: cachedProtectedQuery({ maxAge: CacheTTL.LONG }) diff --git a/packages/server/src/routers/settings.ts b/packages/server/src/routers/settings.ts index 04fb0a4d25..7b37196ac6 100644 --- a/packages/server/src/routers/settings.ts +++ b/packages/server/src/routers/settings.ts @@ -2,6 +2,10 @@ import { medicationRemindersSchema } from "@dofek/format/medication-reminders"; import { PRIMARY_GOAL_SETTINGS_KEY, primaryGoalIds } from "@dofek/onboarding/primary-goal"; import { invalidateAllUserQueries, queryCache } from "dofek/lib/cache"; import { z } from "zod"; +import { + CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY, + climbingGradePreferenceSchema, +} from "../climbing-grade-preferences.ts"; import { PROVIDER_ACCOUNT_TABLES } from "../repositories/provider-detail-repository.ts"; import { SettingsRepository } from "../repositories/settings-repository.ts"; import { CacheTTL, cachedProtectedQuery, protectedProcedure, router } from "../trpc.ts"; @@ -21,6 +25,10 @@ const settingInputSchema = z.discriminatedUnion("key", [ key: z.literal("unitSystem"), value: z.enum(["metric", "imperial"]), }), + z.strictObject({ + key: z.literal(CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY), + value: climbingGradePreferenceSchema, + }), z.strictObject({ key: z.literal("whoop.wearLocation"), value: z.enum(["wrist", "bicep", "chest", "waist", "calf"]), diff --git a/packages/server/src/services/mobile-training-tab.ts b/packages/server/src/services/mobile-training-tab.ts index b6c88fca5b..d5edd8377d 100644 --- a/packages/server/src/services/mobile-training-tab.ts +++ b/packages/server/src/services/mobile-training-tab.ts @@ -4,6 +4,7 @@ import { getEffectiveParams } from "dofek/personalization/params"; import { loadPersonalizedParams } from "dofek/personalization/storage"; import { z } from "zod"; import type { AccessWindow } from "../billing/entitlement.ts"; +import { loadClimbingGradePreference } from "../climbing-grade-preferences.ts"; import { type MobileTrainingTabResult, mobileTrainingTabOutputSchema, @@ -79,7 +80,6 @@ export async function loadMobileTrainingTab( ctx.sensorStore, ctx.accessWindow, ); - const climbingRepo = new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow); const hangboardingRepo = new HangboardingRepository( ctx.db, ctx.userId, @@ -91,8 +91,9 @@ export async function loadMobileTrainingTab( const windowStart = dateWindowStartString(endDate, days); const accessParams = clickHouseDateAccessWindowParams(ctx.accessWindow); - const [storedParams, strainRows, readinessRows] = await Promise.all([ + const [storedParams, climbingGradePreference, strainRows, readinessRows] = await Promise.all([ loadPersonalizedParams(ctx.db, ctx.userId), + loadClimbingGradePreference(ctx.db, ctx.userId), ctx.sensorStore.query( strainRowSchema, `SELECT @@ -143,6 +144,14 @@ export async function loadMobileTrainingTab( ), ]); + const climbingRepo = new ClimbingRepository( + ctx.db, + ctx.userId, + ctx.timezone, + ctx.accessWindow, + climbingGradePreference, + ); + const effective = getEffectiveParams(storedParams); const workloadRatio = computeWorkloadRatio(strainRows); const strainTarget = diff --git a/packages/training/src/climbing-grades.test.ts b/packages/training/src/climbing-grades.test.ts index c11ef28519..c11d5387d8 100644 --- a/packages/training/src/climbing-grades.test.ts +++ b/packages/training/src/climbing-grades.test.ts @@ -46,7 +46,9 @@ describe("parseClimbingGrade", () => { expect(parsedGrades.map((grade) => grade?.grade)).toEqual(["VB", "V0", "V1", "V5", "V10"]); expect(parsedGrades.every((grade) => grade?.gradeSystem === "v_scale")).toBe(true); expect(parsedGrades.map((grade) => grade?.sortValue)).toEqual( - [...parsedGrades.map((grade) => grade?.sortValue)].sort((left, right) => Number(left) - Number(right)), + [...parsedGrades.map((grade) => grade?.sortValue)].sort( + (left, right) => Number(left) - Number(right), + ), ); }); diff --git a/packages/training/src/climbing-grades.ts b/packages/training/src/climbing-grades.ts index 9f823f55ec..42d3d5a3f8 100644 --- a/packages/training/src/climbing-grades.ts +++ b/packages/training/src/climbing-grades.ts @@ -1,6 +1,6 @@ /// -import { convertGrade, getScale, GradeScales } from "@openbeta/sandbag"; +import { convertGrade, GradeScales, getScale } from "@openbeta/sandbag"; export const CLIMBING_GRADE_SYSTEMS = [ "v_scale", @@ -87,7 +87,9 @@ function canonicalGrade(grade: string, system: ClimbingGradeSystem): string | nu const scale = sandbagScale(system); if (!scale.isType(trimmed)) return null; const normalized = trimmed.toLocaleLowerCase(); - const listedGrade = scale.grades.find((candidate) => candidate.toLocaleLowerCase() === normalized); + const listedGrade = scale.grades.find( + (candidate) => candidate.toLocaleLowerCase() === normalized, + ); if (listedGrade) return listedGrade; if (system !== "yds") return null; const yosemiteBase = /^5\.(\d+)([+-])?$/i.exec(trimmed); @@ -122,7 +124,9 @@ export function isGradeSystemForClimbType( system: ClimbingGradeSystem, climbType: ClimbingClimbType, ): boolean { - return gradeSystemsForClimbType(climbType).includes(system as never); + return climbType === "boulder" + ? system === "v_scale" || system === "font" + : system !== "v_scale" && system !== "font"; } export function isValidClimbingGrade(grade: string, system: ClimbingGradeSystem): boolean { diff --git a/packages/web/src/components/ClimbingAttemptLog.test.tsx b/packages/web/src/components/ClimbingAttemptLog.test.tsx index 5ca876ae55..289f82e9e6 100644 --- a/packages/web/src/components/ClimbingAttemptLog.test.tsx +++ b/packages/web/src/components/ClimbingAttemptLog.test.tsx @@ -51,4 +51,25 @@ describe("ClimbingAttemptLog", () => { }), ); }); + + it("uses the selected grade preference for manual logging", () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Grade"), { target: { value: "6a" } }); + fireEvent.click(screen.getByRole("button", { name: "Save climbing session" })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + climbs: [expect.objectContaining({ grade: "6a", gradeSystem: "font" })], + }), + ); + }); }); diff --git a/packages/web/src/components/ClimbingAttemptLog.tsx b/packages/web/src/components/ClimbingAttemptLog.tsx index 6557e7c0b6..07e4e1cb4f 100644 --- a/packages/web/src/components/ClimbingAttemptLog.tsx +++ b/packages/web/src/components/ClimbingAttemptLog.tsx @@ -1,3 +1,10 @@ +import { + type ClimbingGradePreference, + type ClimbingGradeSystem, + DEFAULT_CLIMBING_GRADE_PREFERENCE, + gradeOptionsForSystem, + gradeSystemLabel, +} from "@dofek/training/climbing-grades"; import { useState } from "react"; import { z } from "zod"; @@ -25,7 +32,7 @@ export interface ClimbingSessionSubmission { }>; climbType: "boulder" | "route"; grade: string; - gradeSystem: "v_scale" | "yds"; + gradeSystem: ClimbingGradeSystem; holdType: HoldType | null; routeName: string | null; wallAngleDegrees: number | null; @@ -37,10 +44,12 @@ export interface ClimbingSessionSubmission { export function ClimbingAttemptLog({ errorMessage, + gradePreference = DEFAULT_CLIMBING_GRADE_PREFERENCE, onSubmit, submitting, }: { errorMessage: string | null; + gradePreference?: ClimbingGradePreference; onSubmit: (input: ClimbingSessionSubmission) => void; submitting: boolean; }) { @@ -62,6 +71,9 @@ export function ClimbingAttemptLog({ ); } + const gradeSystem = gradePreference[climbType]; + const grades = gradeOptionsForSystem(gradeSystem); + return (
    setClimbType(climbTypeSchema.parse(event.target.value))} + onChange={(event) => { + setClimbType(climbTypeSchema.parse(event.target.value)); + setGrade(""); + }} value={climbType} > - - + (null); + const [writeError, setWriteError] = useState(null); + const preference = preferenceFrom(setting.data?.value); + + useEffect(() => { + if (setting.error && lastError.current !== setting.error) { + lastError.current = setting.error; + captureException(setting.error, { context: "climbing-grade-systems-read" }); + } + }, [setting.error]); + + function setPreference(next: ClimbingGradePreference): void { + const previous = utils.settings.get.getData({ key: SETTINGS_KEY }); + utils.settings.get.setData({ key: SETTINGS_KEY }, { key: SETTINGS_KEY, value: next }); + setWriteError(null); + mutation.mutate( + { key: SETTINGS_KEY, value: next }, + { + onError: (error) => { + utils.settings.get.setData({ key: SETTINGS_KEY }, previous); + setWriteError(error.message); + captureException(error, { context: "climbing-grade-systems-write" }); + }, + onSettled: () => void utils.settings.get.invalidate({ key: SETTINGS_KEY }), + }, + ); + } + + return ( +
    + setPreference({ ...preference, boulder })} + options={BOULDER_SYSTEMS} + value={preference.boulder} + /> + setPreference({ ...preference, route })} + options={ROUTE_SYSTEMS} + value={preference.route} + /> + {(writeError ?? setting.error?.message) ? ( +

    + {writeError ?? setting.error?.message} +

    + ) : null} +
    + ); +} + +function GradeSystemSelect({ + label, + onChange, + options, + value, +}: { + label: string; + onChange: (value: T) => void; + options: T[]; + value: T; +}) { + return ( + + ); +} diff --git a/packages/web/src/pages/SettingsPage.tsx b/packages/web/src/pages/SettingsPage.tsx index d01bbbd1d2..55aaacddeb 100644 --- a/packages/web/src/pages/SettingsPage.tsx +++ b/packages/web/src/pages/SettingsPage.tsx @@ -2,6 +2,7 @@ import { formatDateMedium, parseValidDate } from "@dofek/format/format"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { AccountErasurePanel } from "../components/AccountErasurePanel.tsx"; +import { ClimbingGradeSystemToggle } from "../components/ClimbingGradeSystemToggle.tsx"; import { DataSourcesPanel } from "../components/DataSourcesPanel.tsx"; import { ExportPanel } from "../components/ExportPanel.tsx"; import { LinkedAccountsPanel } from "../components/LinkedAccountsPanel.tsx"; @@ -378,6 +379,15 @@ export function SettingsPage() { ) : null} + {activeCategory === "goals-models" ? ( + + + + ) : null} + {activeCategory === "notifications" ? ( vi.fn()); const sessionSummaryQuery = vi.hoisted(() => vi.fn()); const hangboardingSummaryQuery = vi.hoisted(() => vi.fn()); const fingerLoadingHistoryQuery = vi.hoisted(() => vi.fn()); +const climbingGradeSettingQuery = vi.hoisted(() => vi.fn()); const logFingerLoadingMutation = vi.hoisted(() => vi.fn()); const logClimbingSessionMutation = vi.hoisted(() => vi.fn()); const recentActivitiesSection = vi.hoisted(() => vi.fn()); @@ -76,6 +77,7 @@ vi.mock("../../lib/trpc.ts", () => ({ sessionSummary: { useQuery: sessionSummaryQuery }, hangboardingSummary: { useQuery: hangboardingSummaryQuery }, }, + settings: { get: { useQuery: climbingGradeSettingQuery } }, useUtils: () => ({ activity: { invalidate: vi.fn() }, climbing: { @@ -100,6 +102,7 @@ describe("ClimbingTab", () => { sessionSummaryQuery.mockReset(); hangboardingSummaryQuery.mockReset(); fingerLoadingHistoryQuery.mockReset(); + climbingGradeSettingQuery.mockReset(); logFingerLoadingMutation.mockReset(); logClimbingSessionMutation.mockReset(); recentActivitiesSection.mockReset(); @@ -109,6 +112,7 @@ describe("ClimbingTab", () => { sessionSummaryQuery.mockReturnValue({ data: [], isLoading: false, error: null }); hangboardingSummaryQuery.mockReturnValue({ data: undefined, isLoading: false, error: null }); fingerLoadingHistoryQuery.mockReturnValue({ data: [], isLoading: false, error: null }); + climbingGradeSettingQuery.mockReturnValue({ data: null, isLoading: false, error: null }); logFingerLoadingMutation.mockReturnValue({ error: null, isPending: false, mutate: vi.fn() }); logClimbingSessionMutation.mockReturnValue({ error: null, isPending: false, mutate: vi.fn() }); }); diff --git a/packages/web/src/routes/training/climbing.tsx b/packages/web/src/routes/training/climbing.tsx index af8270f249..c45626ac59 100644 --- a/packages/web/src/routes/training/climbing.tsx +++ b/packages/web/src/routes/training/climbing.tsx @@ -1,3 +1,7 @@ +import { + type ClimbingGradePreference, + DEFAULT_CLIMBING_GRADE_PREFERENCE, +} from "@dofek/training/climbing-grades"; import { createFileRoute } from "@tanstack/react-router"; import type { ClimbingSessionSummaryRow } from "dofek-server/types"; import type { Activity } from "../../components/ActivityList.tsx"; @@ -31,6 +35,26 @@ function climbingRangeInput(days: number | null): { days?: number } { return days === null ? {} : { days }; } +function resolveGradePreference(value: unknown): ClimbingGradePreference { + if ( + typeof value === "object" && + value !== null && + "boulder" in value && + "route" in value && + (value.boulder === "v_scale" || value.boulder === "font") && + (value.route === "yds" || + value.route === "french" || + value.route === "uiaa" || + value.route === "ewbank" || + value.route === "saxon" || + value.route === "norwegian" || + value.route === "brazilian_crux") + ) { + return { boulder: value.boulder, route: value.route }; + } + return DEFAULT_CLIMBING_GRADE_PREFERENCE; +} + function climbingSessionColumns( sessionSummaries: ClimbingSessionSummaryRow[], ): Array> { @@ -97,6 +121,8 @@ export function ClimbingTab() { TRAINING_SLOW_QUERY_OPTIONS, ); const fingerLoadingHistory = trpc.climbing.fingerLoadingHistory.useQuery({ days: 90 }); + const gradePreferenceSetting = trpc.settings.get.useQuery({ key: "climbingGradeSystems" }); + const gradePreference = resolveGradePreference(gradePreferenceSetting.data?.value); const utils = trpc.useUtils(); const fingerLoadingMutation = trpc.climbing.logFingerLoading.useMutation({ meta: { errorReportedLocally: true }, @@ -171,6 +197,7 @@ export function ClimbingTab() { > climbingSessionMutation.mutate(input)} submitting={climbingSessionMutation.isPending} /> diff --git a/src/db/drizzle-schema.test.ts b/src/db/drizzle-schema.test.ts index 180eef0923..8b474c1f02 100644 --- a/src/db/drizzle-schema.test.ts +++ b/src/db/drizzle-schema.test.ts @@ -328,6 +328,16 @@ describe("drizzleSchema", () => { expect(climbingClimbTypeEnum.enumName).toBe("climbing_climb_type"); expect(climbingClimbTypeEnum.enumValues).toEqual(["boulder", "route"]); expect(climbingGradeSystemEnum.enumName).toBe("climbing_grade_system"); - expect(climbingGradeSystemEnum.enumValues).toEqual(["v_scale", "yds"]); + expect(climbingGradeSystemEnum.enumValues).toEqual([ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", + ]); }); }); diff --git a/src/db/schema/enums.ts b/src/db/schema/enums.ts index 522cd2056e..ae5a3fa925 100644 --- a/src/db/schema/enums.ts +++ b/src/db/schema/enums.ts @@ -47,7 +47,17 @@ export const setTypeEnum = fitness.enum("set_type", ["working", "warmup", "drops export const climbingClimbTypeEnum = fitness.enum("climbing_climb_type", ["boulder", "route"]); -export const climbingGradeSystemEnum = fitness.enum("climbing_grade_system", ["v_scale", "yds"]); +export const climbingGradeSystemEnum = fitness.enum("climbing_grade_system", [ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", +]); export const fingerLoadingExerciseEnum = fitness.enum("finger_loading_exercise", [ "max_hang", From 97081cb106efd00758b20b6dfd1efd8558d8c414 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 11:57:21 -0700 Subject: [PATCH 37/46] fix(training): avoid dynamic grade regex --- packages/training/src/climbing-grades.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/training/src/climbing-grades.ts b/packages/training/src/climbing-grades.ts index 42d3d5a3f8..bb61a23d2a 100644 --- a/packages/training/src/climbing-grades.ts +++ b/packages/training/src/climbing-grades.ts @@ -95,9 +95,10 @@ function canonicalGrade(grade: string, system: ClimbingGradeSystem): string | nu const yosemiteBase = /^5\.(\d+)([+-])?$/i.exec(trimmed); if (!yosemiteBase) return null; const major = yosemiteBase[1]; - const hasKnownSubdivision = scale.grades.some((candidate) => - new RegExp(`^5\\.${major}[abcd]$`, "i").test(candidate), - ); + const hasKnownSubdivision = scale.grades.some((candidate) => { + const subdivision = /^5\.(\d+)[abcd]$/i.exec(candidate); + return subdivision?.[1] === major; + }); return hasKnownSubdivision ? `5.${major}${yosemiteBase[2] ?? ""}` : null; } From 78a0abf69f446d9e5fa138ad249e870537112fdf Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 13:15:12 -0700 Subject: [PATCH 38/46] fix(ci): satisfy climbing grade checks --- cspell.json | 6 +- packages/server/src/routers/climbing.ts | 1 - .../ClimbingGradeSystemToggle.stories.tsx | 59 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx diff --git a/cspell.json b/cspell.json index 33448113b3..c8132d5077 100644 --- a/cspell.json +++ b/cspell.json @@ -657,7 +657,11 @@ "trkpt", "trkseg", "Mesgs", - "mesg" + "mesg", + "openbeta", + "uiaa", + "ewbank", + "vscale" ], "flagWords": [], "useGitignore": false, diff --git a/packages/server/src/routers/climbing.ts b/packages/server/src/routers/climbing.ts index 273262be32..acd023afc3 100644 --- a/packages/server/src/routers/climbing.ts +++ b/packages/server/src/routers/climbing.ts @@ -277,5 +277,4 @@ export const climbingRouter = router({ ); return runClimbingQuery(() => repository.getSummary(input.days)); }), - }); diff --git a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx new file mode 100644 index 0000000000..deb98c41d7 --- /dev/null +++ b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { OperationResultObservable, TRPCLink } from "@trpc/client"; +import type { AppRouter } from "dofek-server/router"; +import { useMemo } from "react"; +import { trpc } from "../lib/trpc.ts"; +import { ClimbingGradeSystemToggle } from "./ClimbingGradeSystemToggle.tsx"; + +function createMockLink(): TRPCLink { + return () => + ({ op }) => + createMockObservable( + op.path === "settings.get" + ? { key: "climbingGradeSystems", value: { boulder: "font", route: "french" } } + : { key: "climbingGradeSystems", value: op.input }, + ); +} + +function createMockObservable(data: unknown): OperationResultObservable { + const result: OperationResultObservable = { + subscribe(observer) { + observer.next?.({ result: { data } }); + observer.complete?.(); + return { unsubscribe: () => {} }; + }, + pipe() { + return result; + }, + }; + return result; +} + +function ClimbingGradeSystemStoryFrame() { + const queryClient = useMemo(() => new QueryClient(), []); + const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink()] }), []); + + return ( + + +
    + +
    +
    +
    + ); +} + +const meta = { + title: "Settings/ClimbingGradeSystemToggle", + component: ClimbingGradeSystemToggle, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FontAndFrench: Story = { + render: () => , +}; From d8109c9ba5337ee794240e93c97b197ac0e75e81 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 13:23:27 -0700 Subject: [PATCH 39/46] fix(climbing): preserve session summary details --- .../repositories/climbing-repository.test.ts | 70 +++++++++++++++++++ .../src/repositories/climbing-repository.ts | 5 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index 12e14d9b65..cfc0d05d19 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -356,6 +356,37 @@ describe("ClimbingRepository", () => { expect(text).toContain("attempt_count"); expect(text).toContain("ce.grade_system"); }); + + it("keeps a non-null location from a later entry in the same activity", async () => { + const { repo } = makeRepository([ + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: null, + attempt_count: 1, + sent: false, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V3", + }, + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: "Pacific Pipe", + attempt_count: 1, + sent: true, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + }, + ]); + + const [summary] = await repo.getSessionSummaries(90); + + expect(summary?.toDetail().locationName).toBe("Pacific Pipe"); + }); }); describe("getActivityEntries", () => { @@ -414,5 +445,44 @@ describe("ClimbingRepository", () => { expect(text).toContain("a.user_id = "); expect(text).toContain("ORDER BY"); }); + + it("keeps activity entries with invalid grades in deterministic source order", async () => { + const { repo } = makeRepository([ + { + id: "entry-1", + climb_type: "boulder", + grade_system: "v_scale", + grade: "not-a-grade", + sent: false, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + { + id: "entry-2", + climb_type: "boulder", + grade_system: "v_scale", + grade: "also-not-a-grade", + sent: false, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + ]); + + const entries = await repo.getActivityEntries("activity-1"); + + expect(entries.map((entry) => entry.toDetail().id)).toEqual(["entry-1", "entry-2"]); + }); }); }); diff --git a/packages/server/src/repositories/climbing-repository.ts b/packages/server/src/repositories/climbing-repository.ts index 839d60b83f..0d0a9d9135 100644 --- a/packages/server/src/repositories/climbing-repository.ts +++ b/packages/server/src/repositories/climbing-repository.ts @@ -343,6 +343,9 @@ export class ClimbingRepository extends BaseRepository { hardestRouteGrade: null, hardestRouteGradeSortValue: null, }; + if (existing.locationName === null && row.location_name !== null) { + existing.locationName = row.location_name; + } existing.attempts += row.attempt_count; if (row.sent) existing.sends += 1; const display = row.sent @@ -420,7 +423,7 @@ export class ClimbingRepository extends BaseRepository { display: { grade: row.grade, gradeSystem: row.grade_system, - gradeSortValue: -Infinity, + gradeSortValue: -1e9, }, }; }) From adc06982af64495f6c8200064077f4dc81c06c13 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 13:56:26 -0700 Subject: [PATCH 40/46] fix(climbing): address grade system review feedback --- .../2026-08-11-climbing-grade-systems.md | 4 +- drizzle/0074_climbing_grade_systems.sql | 14 +- packages/mobile/app/climbing-log.tsx | 50 +++---- packages/mobile/app/settings.styles.ts | 5 + packages/mobile/app/settings.tsx | 126 ++++++++---------- .../components/ClimbingAttemptLog.test.tsx | 23 +++- .../mobile/components/ClimbingAttemptLog.tsx | 13 +- .../mobile-dashboard-contracts.test.ts | 13 ++ .../contracts/mobile-dashboard-contracts.ts | 53 +++----- .../src/repositories/climbing-repository.ts | 19 +-- .../src/routers/climbing.integration.test.ts | 32 +++-- packages/server/src/routers/climbing.test.ts | 17 +-- packages/server/src/routers/climbing.ts | 49 +++---- ...settings-sleep-need-sport-settings.test.ts | 25 ++++ packages/server/src/routers/settings.ts | 12 +- packages/training/src/climbing-grades.test.ts | 32 +++++ packages/training/src/climbing-grades.ts | 49 ++++++- .../ClimbingGradeSystemToggle.stories.tsx | 38 +++++- .../components/ClimbingGradeSystemToggle.tsx | 37 +++-- packages/web/src/routes/training/climbing.tsx | 47 +++---- 20 files changed, 368 insertions(+), 290 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md index 60c67bd4f9..0bc96011a0 100644 --- a/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md +++ b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md @@ -36,9 +36,9 @@ ```ts expect(gradeSystemsForClimbType("boulder")).toEqual(["v_scale", "font"]); -expect(gradeOptionsForSystem("font")).toContain("6A"); +expect(gradeOptionsForSystem("font")).toContain("6a"); expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "font" })) - .toMatchObject({ displaySystem: "font", displayGrade: "6b+" }); + .toMatchObject({ displaySystem: "font", displayGrade: "6a+/6b+" }); expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "yds" })) .toBeNull(); ``` diff --git a/drizzle/0074_climbing_grade_systems.sql b/drizzle/0074_climbing_grade_systems.sql index 3534d89593..9e025422e5 100644 --- a/drizzle/0074_climbing_grade_systems.sql +++ b/drizzle/0074_climbing_grade_systems.sql @@ -1,7 +1,7 @@ -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'font'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'french'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'uiaa'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'ewbank'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'saxon'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'norwegian'; -ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'brazilian_crux'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'font' AFTER 'v_scale'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'french' AFTER 'yds'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'uiaa' AFTER 'french'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'ewbank' AFTER 'uiaa'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'saxon' AFTER 'ewbank'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'norwegian' AFTER 'saxon'; +ALTER TYPE fitness.climbing_grade_system ADD VALUE IF NOT EXISTS 'brazilian_crux' AFTER 'norwegian'; diff --git a/packages/mobile/app/climbing-log.tsx b/packages/mobile/app/climbing-log.tsx index 0e2d8b48de..bf42334859 100644 --- a/packages/mobile/app/climbing-log.tsx +++ b/packages/mobile/app/climbing-log.tsx @@ -1,7 +1,4 @@ -import { - type ClimbingGradePreference, - DEFAULT_CLIMBING_GRADE_PREFERENCE, -} from "@dofek/training/climbing-grades"; +import { resolveClimbingGradePreference } from "@dofek/training/climbing-grades"; import { useEffect, useRef } from "react"; import { ScrollView, StyleSheet, Text, View } from "react-native"; import { @@ -21,7 +18,9 @@ export default function ClimbingLogScreen() { const history = trpc.climbing.fingerLoadingHistory.useQuery({ days: 90 }); const gradePreferenceSetting = trpc.settings.get.useQuery({ key: "climbingGradeSystems" }); const lastHistoryError = useRef(null); - const gradePreference = resolveGradePreference(gradePreferenceSetting.data?.value); + const gradePreference = gradePreferenceSetting.data + ? resolveClimbingGradePreference(gradePreferenceSetting.data.value) + : null; useEffect(() => { if (history.error && lastHistoryError.current !== history.error) { @@ -80,37 +79,26 @@ export default function ClimbingLogScreen() { Capture the wall, holds, result, and reason for every attempt. - climbingMutation.mutate(input)} - submitting={climbingMutation.isPending} - /> + {gradePreferenceSetting.error && !gradePreference ? ( + + ) : gradePreference ? ( + climbingMutation.mutate(input)} + submitting={climbingMutation.isPending} + /> + ) : ( + + )} ); } -function resolveGradePreference(value: unknown): ClimbingGradePreference { - if ( - typeof value === "object" && - value !== null && - "boulder" in value && - "route" in value && - (value.boulder === "v_scale" || value.boulder === "font") && - (value.route === "yds" || - value.route === "french" || - value.route === "uiaa" || - value.route === "ewbank" || - value.route === "saxon" || - value.route === "norwegian" || - value.route === "brazilian_crux") - ) { - return { boulder: value.boulder, route: value.route }; - } - return DEFAULT_CLIMBING_GRADE_PREFERENCE; -} - const styles = StyleSheet.create({ container: { backgroundColor: colors.background, flex: 1 }, content: { gap: spacing.lg, padding: spacing.md }, diff --git a/packages/mobile/app/settings.styles.ts b/packages/mobile/app/settings.styles.ts index ecb25fbfe9..b939c3ab00 100644 --- a/packages/mobile/app/settings.styles.ts +++ b/packages/mobile/app/settings.styles.ts @@ -81,6 +81,11 @@ export const styles = StyleSheet.create({ color: colors.textTertiary, marginBottom: 10, }, + label: { + color: colors.textSecondary, + fontSize: 13, + marginBottom: 6, + }, // ── Billing ── billingStatusText: { diff --git a/packages/mobile/app/settings.tsx b/packages/mobile/app/settings.tsx index 4fcadbe16e..34add09edf 100644 --- a/packages/mobile/app/settings.tsx +++ b/packages/mobile/app/settings.tsx @@ -8,9 +8,9 @@ import { formatDateMedium, formatDateTime } from "@dofek/format/format"; import { type BoulderGradeSystem, type ClimbingGradePreference, - DEFAULT_CLIMBING_GRADE_PREFERENCE, gradeSystemLabel, type RouteGradeSystem, + resolveClimbingGradePreference, } from "@dofek/training/climbing-grades"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as Updates from "expo-updates"; @@ -276,7 +276,9 @@ export default function SettingsScreen() { const currentUnitSystem: UnitSystem = unitSetting.data?.value === "imperial" ? "imperial" : "metric"; - const climbingGradePreference = resolveGradePreference(climbingGradeSetting.data?.value); + const climbingGradePreference = climbingGradeSetting.data + ? resolveClimbingGradePreference(climbingGradeSetting.data.value) + : null; async function startCheckout(): Promise { setCheckoutClientError(null); @@ -530,55 +532,61 @@ export default function SettingsScreen() { Choose the grade systems used for boulders and routes - {climbingGradeSetting.error ? ( + {climbingGradeSetting.error && !climbingGradePreference ? ( {climbingGradeSetting.error.message} + ) : climbingGradePreference ? null : ( + + )} + {climbingGradePreference ? Boulder grades : null} + {climbingGradePreference ? ( + + {BOULDER_GRADE_SYSTEMS.map((value) => { + const selected = climbingGradePreference.boulder === value; + return ( + + handleClimbingGradeChange({ ...climbingGradePreference, boulder: value }) + } + disabled={setSettingMutation.isPending} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: setSettingMutation.isPending }} + > + + {gradeSystemLabel(value)} + + + ); + })} + + ) : null} + {climbingGradePreference ? Route grades : null} + {climbingGradePreference ? ( + + {ROUTE_GRADE_SYSTEMS.map((value) => { + const selected = climbingGradePreference.route === value; + return ( + + handleClimbingGradeChange({ ...climbingGradePreference, route: value }) + } + disabled={setSettingMutation.isPending} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: setSettingMutation.isPending }} + > + + {gradeSystemLabel(value)} + + + ); + })} + ) : null} - Boulder grades - - {BOULDER_GRADE_SYSTEMS.map((value) => { - const selected = climbingGradePreference.boulder === value; - return ( - - handleClimbingGradeChange({ ...climbingGradePreference, boulder: value }) - } - disabled={setSettingMutation.isPending} - accessibilityRole="button" - accessibilityLabel={gradeSystemLabel(value)} - accessibilityState={{ selected, disabled: setSettingMutation.isPending }} - > - - {gradeSystemLabel(value)} - - - ); - })} - - Route grades - - {ROUTE_GRADE_SYSTEMS.map((value) => { - const selected = climbingGradePreference.route === value; - return ( - - handleClimbingGradeChange({ ...climbingGradePreference, route: value }) - } - disabled={setSettingMutation.isPending} - accessibilityRole="button" - accessibilityLabel={gradeSystemLabel(value)} - accessibilityState={{ selected, disabled: setSettingMutation.isPending }} - > - - {gradeSystemLabel(value)} - - - ); - })} - ) : null} @@ -993,23 +1001,3 @@ export default function SettingsScreen() { ); } - -function resolveGradePreference(value: unknown): ClimbingGradePreference { - if ( - typeof value === "object" && - value !== null && - "boulder" in value && - "route" in value && - (value.boulder === "v_scale" || value.boulder === "font") && - (value.route === "yds" || - value.route === "french" || - value.route === "uiaa" || - value.route === "ewbank" || - value.route === "saxon" || - value.route === "norwegian" || - value.route === "brazilian_crux") - ) { - return { boulder: value.boulder, route: value.route }; - } - return DEFAULT_CLIMBING_GRADE_PREFERENCE; -} diff --git a/packages/mobile/components/ClimbingAttemptLog.test.tsx b/packages/mobile/components/ClimbingAttemptLog.test.tsx index 65d43e964d..f52f0df094 100644 --- a/packages/mobile/components/ClimbingAttemptLog.test.tsx +++ b/packages/mobile/components/ClimbingAttemptLog.test.tsx @@ -9,9 +9,7 @@ describe("mobile ClimbingAttemptLog", () => { const onSubmit = vi.fn(); render(); - const gradeInput = screen.getAllByRole("textbox")[0]; - if (!gradeInput) throw new Error("Grade input is required"); - fireEvent.change(gradeInput, { target: { value: "V5" } }); + fireEvent.click(screen.getByLabelText("Grade (V Scale) V5")); fireEvent.click(screen.getByLabelText("Attempt 1 reason Technique")); fireEvent.click(screen.getByLabelText("Add climbing attempt")); fireEvent.click(screen.getByLabelText("Attempt 2 outcome Sent")); @@ -43,9 +41,7 @@ describe("mobile ClimbingAttemptLog", () => { />, ); - const gradeInput = screen.getAllByRole("textbox")[0]; - if (!gradeInput) throw new Error("Grade input is required"); - fireEvent.change(gradeInput, { target: { value: "6a" } }); + fireEvent.click(screen.getByLabelText("Grade (Fontainebleau) 6a")); fireEvent.click(screen.getByLabelText("Save climbing session")); expect(onSubmit).toHaveBeenCalledWith( @@ -54,4 +50,19 @@ describe("mobile ClimbingAttemptLog", () => { }), ); }); + + it("clears a selected grade when the climb type changes", () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Grade (V Scale) V5")); + fireEvent.click(screen.getByLabelText("Climb type Route")); + fireEvent.click(screen.getByLabelText("Save climbing session")); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + climbs: [expect.objectContaining({ grade: "", gradeSystem: "yds" })], + }), + ); + }); }); diff --git a/packages/mobile/components/ClimbingAttemptLog.tsx b/packages/mobile/components/ClimbingAttemptLog.tsx index ab08e1bc99..fc51c82255 100644 --- a/packages/mobile/components/ClimbingAttemptLog.tsx +++ b/packages/mobile/components/ClimbingAttemptLog.tsx @@ -2,6 +2,7 @@ import { type ClimbingGradePreference, type ClimbingGradeSystem, DEFAULT_CLIMBING_GRADE_PREFERENCE, + gradeOptionsForSystem, gradeSystemLabel, } from "@dofek/training/climbing-grades"; import { useState } from "react"; @@ -88,17 +89,21 @@ export function ClimbingAttemptLog({ { + setClimbType(nextClimbType); + setGrade(""); + }} options={[ { label: "Boulder", value: "boulder" }, { label: "Route", value: "route" }, ]} selected={climbType} /> - ({ label: value, value }))} + selected={grade} /> diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index fb1a8ad813..1f3256ac85 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -954,6 +954,19 @@ describe("mobileTrainingFixtureSchema", () => { ); }); + it("rejects climbing grade systems from the wrong discipline", () => { + const fixture = validTrainingFixture(); + fixture.data.climbing.gradeProgression.push({ + date: input.endDate, + climbType: "boulder", + gradeSystem: "yds", + grade: "5.10a", + gradeSortValue: 63.5, + }); + + expect(mobileTrainingFixtureSchema.safeParse(fixture).success).toBe(false); + }); + it("rejects weekly volume rows that do not start on Monday", () => { const fixture = validTrainingFixture(); const cyclingVolume = fixture.data.weeklyVolume[0]; diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index bfbfe72e4e..3684d846c9 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -1,5 +1,6 @@ import { activityDataStateSchema } from "@dofek/format/activity-data-state"; import { ACTIVITY_MODALITIES } from "@dofek/training/activity-types"; +import { CLIMBING_GRADE_SYSTEMS, isGradeSystemForClimbType } from "@dofek/training/climbing-grades"; import { z } from "zod"; import { baselineComparisonDirectionSchema, @@ -15,6 +16,22 @@ const score100Schema = z.number().min(0).max(100); const strainScoreSchema = z.number().min(0).max(21); const stressScoreSchema = z.number().min(0).max(3); const nonnegativeNumberSchema = z.number().min(0); +const climbingGradeDisplaySchema = z + .object({ + climbType: z.enum(["boulder", "route"]), + gradeSystem: z.enum(CLIMBING_GRADE_SYSTEMS), + grade: z.string(), + gradeSortValue: z.number(), + }) + .superRefine((grade, context) => { + if (!isGradeSystemForClimbType(grade.gradeSystem, grade.climbType)) { + context.addIssue({ + code: "custom", + message: "Grade system must match the climb type", + path: ["gradeSystem"], + }); + } + }); export const workloadRatioResultSchema = z.object({ context: z.object({ @@ -349,41 +366,9 @@ export const mobileTrainingTabOutputSchema = z.object({ verticalAscent: trainingChartAvailabilitySchema, }), climbing: z.object({ - gradeProgression: z.array( - z.object({ - date: dateSchema, - climbType: z.enum(["boulder", "route"]), - gradeSystem: z.enum([ - "v_scale", - "font", - "yds", - "french", - "uiaa", - "ewbank", - "saxon", - "norwegian", - "brazilian_crux", - ]), - grade: z.string(), - gradeSortValue: z.number(), - }), - ), + gradeProgression: z.array(climbingGradeDisplaySchema.extend({ date: dateSchema })), volumeByGrade: z.array( - z.object({ - climbType: z.enum(["boulder", "route"]), - gradeSystem: z.enum([ - "v_scale", - "font", - "yds", - "french", - "uiaa", - "ewbank", - "saxon", - "norwegian", - "brazilian_crux", - ]), - grade: z.string(), - gradeSortValue: z.number(), + climbingGradeDisplaySchema.extend({ attempts: z.number().int().nonnegative(), sends: z.number().int().nonnegative(), }), diff --git a/packages/server/src/repositories/climbing-repository.ts b/packages/server/src/repositories/climbing-repository.ts index 0d0a9d9135..f77126d056 100644 --- a/packages/server/src/repositories/climbing-repository.ts +++ b/packages/server/src/repositories/climbing-repository.ts @@ -1,4 +1,5 @@ import { + CLIMBING_GRADE_SYSTEMS, type ClimbingClimbType, type ClimbingGradePreference, type ClimbingGradeSystem, @@ -81,17 +82,7 @@ export class ClimbingSessionSummary { } const climbTypeSchema = z.enum(["boulder", "route"]); -const gradeSystemSchema = z.enum([ - "v_scale", - "font", - "yds", - "french", - "uiaa", - "ewbank", - "saxon", - "norwegian", - "brazilian_crux", -]); +const gradeSystemSchema = z.enum(CLIMBING_GRADE_SYSTEMS); const ascentTypeSchema = z.enum(["Flash", "Onsight", "Redpoint", "Repeat"]); const attemptOutcomeSchema = z.enum(["sent", "failed"]); const failureReasonSchema = z.enum(["fell", "pumped", "skin", "technique", "fear"]); @@ -427,7 +418,11 @@ export class ClimbingRepository extends BaseRepository { }, }; }) - .sort((left, right) => right.display.gradeSortValue - left.display.gradeSortValue) + .sort( + (left, right) => + right.display.gradeSortValue - left.display.gradeSortValue || + left.row.id.localeCompare(right.row.id), + ) .map( ({ row, display }) => new ClimbingActivityEntry({ diff --git a/packages/server/src/routers/climbing.integration.test.ts b/packages/server/src/routers/climbing.integration.test.ts index 70c039a6a4..80423fae5a 100644 --- a/packages/server/src/routers/climbing.integration.test.ts +++ b/packages/server/src/routers/climbing.integration.test.ts @@ -290,8 +290,16 @@ describe("climbing router integration", () => { expect(gradeProgression).toEqual( expect.arrayContaining([ - expect.objectContaining({ climbType: "boulder", grade: "V4", gradeSortValue: 4 }), - expect.objectContaining({ climbType: "route", grade: "5.10a", gradeSortValue: 5101 }), + expect.objectContaining({ + climbType: "boulder", + grade: "V4", + gradeSortValue: expect.any(Number), + }), + expect.objectContaining({ + climbType: "route", + grade: "5.10a", + gradeSortValue: expect.any(Number), + }), ]), ); expect(volumeByGrade).toEqual( @@ -343,11 +351,11 @@ describe("climbing router integration", () => { await expect(caller.activityEntries({ id: visibleClimbingActivityId })).resolves.toEqual([ expect.objectContaining({ climbType: "boulder", - grade: "V2", - routeName: "Warmup", - sent: true, - attemptCount: 2, - ascentType: "Redpoint", + grade: "V5", + routeName: "Project", + sent: false, + attemptCount: 4, + ascentType: null, }), expect.objectContaining({ climbType: "boulder", @@ -359,11 +367,11 @@ describe("climbing router integration", () => { }), expect.objectContaining({ climbType: "boulder", - grade: "V5", - routeName: "Project", - sent: false, - attemptCount: 4, - ascentType: null, + grade: "V2", + routeName: "Warmup", + sent: true, + attemptCount: 2, + ascentType: "Redpoint", }), ]); }); diff --git a/packages/server/src/routers/climbing.test.ts b/packages/server/src/routers/climbing.test.ts index 26150645e9..c190cf644d 100644 --- a/packages/server/src/routers/climbing.test.ts +++ b/packages/server/src/routers/climbing.test.ts @@ -1,3 +1,4 @@ +import type { ClimbingGradeSystem } from "@dofek/training/climbing-grades"; import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { @@ -114,24 +115,16 @@ function makeClimbingSessionInput({ endedAt?: string | null; failureReason?: "fell" | null; grade?: string; - gradeSystem?: - | "v_scale" - | "font" - | "yds" - | "french" - | "uiaa" - | "ewbank" - | "saxon" - | "norwegian" - | "brazilian_crux"; + gradeSystem?: ClimbingGradeSystem; outcome?: "failed" | "sent"; } = {}) { + const defaultGrade = gradeSystem === "v_scale" ? "V5" : gradeSystem === "font" ? "6a" : "5.11a"; return { climbs: [ { attempts: [{ failureReason, notes: null, outcome }], climbType, - grade: grade ?? (climbType === "boulder" ? "V5" : "5.11a"), + grade: grade ?? defaultGrade, gradeSystem, holdType: "crimp" as const, routeName: null, @@ -586,7 +579,6 @@ describe("climbingRouter", () => { makeClimbingSessionInput({ failureReason: null, outcome: "sent" }), ), ).rejects.toMatchObject>({ code: "INTERNAL_SERVER_ERROR" }); - const invalidSent = makeMutationCaller(); await expect( invalidSent.caller.logClimbingSession( @@ -642,6 +634,7 @@ describe("climbingRouter", () => { makeClimbingSessionInput({ climbType: "boulder", grade: "6a", gradeSystem: "font" }), ), ).rejects.toMatchObject>({ code: "INTERNAL_SERVER_ERROR" }); + expect(validFont.transaction).toHaveBeenCalledTimes(1); const invalidGrade = makeMutationCaller(); await expect( diff --git a/packages/server/src/routers/climbing.ts b/packages/server/src/routers/climbing.ts index acd023afc3..3a59cdd7cc 100644 --- a/packages/server/src/routers/climbing.ts +++ b/packages/server/src/routers/climbing.ts @@ -26,7 +26,13 @@ import { fingerLoadingLateralitySchema, } from "../repositories/climbing-training-log-repository.ts"; import { HangboardingRepository } from "../repositories/hangboarding-repository.ts"; -import { CacheTTL, cachedProtectedQuery, protectedProcedure, router } from "../trpc.ts"; +import { + type AuthenticatedContext, + CacheTTL, + cachedProtectedQuery, + protectedProcedure, + router, +} from "../trpc.ts"; const daysInputSchema = z.object({ days: z.number().int().min(1).max(365).default(90) }); const nullableNoteSchema = z.string().trim().min(1).max(500).nullable().default(null); @@ -153,6 +159,11 @@ async function runClimbingMutation(input: { } } +async function createClimbingRepository(ctx: AuthenticatedContext): Promise { + const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); + return new ClimbingRepository(ctx.db, ctx.userId, ctx.timezone, ctx.accessWindow, preference); +} + export const climbingRouter = router({ logFingerLoading: protectedProcedure .input(fingerLoadingInputSchema) @@ -206,14 +217,7 @@ export const climbingRouter = router({ .input(z.object({ id: z.guid() })) .query(async ({ ctx, input }): Promise => { return runClimbingQuery(async () => { - const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); - const repository = new ClimbingRepository( - ctx.db, - ctx.userId, - ctx.timezone, - ctx.accessWindow, - preference, - ); + const repository = await createClimbingRepository(ctx); return (await repository.getActivityEntries(input.id)).map((row) => row.toDetail()); }); }), @@ -222,14 +226,7 @@ export const climbingRouter = router({ .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { return runClimbingQuery(async () => { - const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); - const repository = new ClimbingRepository( - ctx.db, - ctx.userId, - ctx.timezone, - ctx.accessWindow, - preference, - ); + const repository = await createClimbingRepository(ctx); return (await repository.getGradeProgression(input.days)).map((row) => row.toDetail()); }); }), @@ -238,14 +235,7 @@ export const climbingRouter = router({ .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { return runClimbingQuery(async () => { - const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); - const repository = new ClimbingRepository( - ctx.db, - ctx.userId, - ctx.timezone, - ctx.accessWindow, - preference, - ); + const repository = await createClimbingRepository(ctx); return (await repository.getVolumeByGrade(input.days)).map((row) => row.toDetail()); }); }), @@ -254,14 +244,7 @@ export const climbingRouter = router({ .input(daysInputSchema) .query(async ({ ctx, input }): Promise => { return runClimbingQuery(async () => { - const preference = await loadClimbingGradePreference(ctx.db, ctx.userId); - const repository = new ClimbingRepository( - ctx.db, - ctx.userId, - ctx.timezone, - ctx.accessWindow, - preference, - ); + const repository = await createClimbingRepository(ctx); return (await repository.getSessionSummaries(input.days)).map((row) => row.toDetail()); }); }), diff --git a/packages/server/src/routers/recovery-settings-sleep-need-sport-settings.test.ts b/packages/server/src/routers/recovery-settings-sleep-need-sport-settings.test.ts index 1480251c78..c4bfc48f03 100644 --- a/packages/server/src/routers/recovery-settings-sleep-need-sport-settings.test.ts +++ b/packages/server/src/routers/recovery-settings-sleep-need-sport-settings.test.ts @@ -622,6 +622,31 @@ describe("settingsRouter", () => { expect(invalidateByPrefix).toHaveBeenCalledWith("user-1:settings."); }); + it("invalidates all grade-dependent queries after changing climbing grade systems", async () => { + const execute = vi + .fn() + .mockResolvedValue([ + { key: "climbingGradeSystems", value: { boulder: "font", route: "french" } }, + ]); + const invalidateByPrefix = vi.mocked(queryCache.invalidateByPrefix); + invalidateByPrefix.mockClear(); + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "UTC", + sensorStore: makeMockSensorStore([]), + }); + + await caller.set({ + key: "climbingGradeSystems", + value: { boulder: "font", route: "french" }, + }); + + expect(invalidateByPrefix).toHaveBeenCalledWith("user-1:settings."); + expect(invalidateByPrefix).toHaveBeenCalledWith("user-1:climbing."); + expect(invalidateByPrefix).toHaveBeenCalledWith("user-1:mobileDashboard.training"); + }); + it("throws when upsert fails", async () => { const caller = createCaller({ db: { execute: vi.fn().mockResolvedValue([]) }, diff --git a/packages/server/src/routers/settings.ts b/packages/server/src/routers/settings.ts index 7b37196ac6..2305eedc70 100644 --- a/packages/server/src/routers/settings.ts +++ b/packages/server/src/routers/settings.ts @@ -60,9 +60,15 @@ export const settingsRouter = router({ const repo = new SettingsRepository(ctx.db, ctx.userId); const result = await repo.set(input.key, input.value); - // Invalidate server-side cache for settings.get and settings.getAll - // so subsequent reads return the updated value, not stale cached data. - await queryCache.invalidateByPrefix(`${ctx.userId}:settings.`); + if (input.key === CLIMBING_GRADE_PREFERENCE_SETTINGS_KEY) { + await Promise.all([ + queryCache.invalidateByPrefix(`${ctx.userId}:settings.`), + queryCache.invalidateByPrefix(`${ctx.userId}:climbing.`), + queryCache.invalidateByPrefix(`${ctx.userId}:mobileDashboard.training`), + ]); + } else { + await queryCache.invalidateByPrefix(`${ctx.userId}:settings.`); + } return result; }), diff --git a/packages/training/src/climbing-grades.test.ts b/packages/training/src/climbing-grades.test.ts index c11d5387d8..259c376a59 100644 --- a/packages/training/src/climbing-grades.test.ts +++ b/packages/training/src/climbing-grades.test.ts @@ -6,6 +6,7 @@ import { isGradeSystemForClimbType, isValidClimbingGrade, parseClimbingGrade, + resolveClimbingGradePreference, } from "./climbing-grades.ts"; describe("parseClimbingGrade", () => { @@ -40,6 +41,37 @@ describe("parseClimbingGrade", () => { }), ).toBeNull(); }); + + it.each([ + "v_scale", + "font", + "yds", + "french", + "uiaa", + "ewbank", + "saxon", + "norwegian", + "brazilian_crux", + ] as const)("validates and preserves %s grades", (system) => { + const grade = gradeOptionsForSystem(system)[0]; + if (!grade) throw new Error(`Sandbag returned no grades for ${system}`); + + expect(isValidClimbingGrade(grade, system)).toBe(true); + expect( + convertClimbingGrade({ grade, sourceSystem: system, displaySystem: system }), + ).toMatchObject({ displayGrade: grade, displaySystem: system }); + }); + + it("uses defaults only when a grade preference is malformed or absent", () => { + expect(resolveClimbingGradePreference({ boulder: "font", route: "french" })).toEqual({ + boulder: "font", + route: "french", + }); + expect(resolveClimbingGradePreference({ boulder: "font", route: "v_scale" })).toEqual({ + boulder: "v_scale", + route: "yds", + }); + }); it("normalizes V-scale grades and orders them by Sandbag score", () => { const parsedGrades = ["VB", "V0", "V1", "V5", "V10"].map(parseClimbingGrade); diff --git a/packages/training/src/climbing-grades.ts b/packages/training/src/climbing-grades.ts index bb61a23d2a..1aec69562f 100644 --- a/packages/training/src/climbing-grades.ts +++ b/packages/training/src/climbing-grades.ts @@ -58,15 +58,18 @@ const SYSTEM_LABELS: Record = { font: "Fontainebleau", yds: "Yosemite Decimal System", french: "French", - uiaa: "UIAA", + uiaa: "International Climbing and Mountaineering Federation (UIAA)", ewbank: "Ewbank", saxon: "Saxon", norwegian: "Norwegian", brazilian_crux: "Brazilian Crux", }; -const BOULDER_SYSTEMS: readonly BoulderGradeSystem[] = ["v_scale", "font"]; -const ROUTE_SYSTEMS: readonly RouteGradeSystem[] = [ +export const BOULDER_GRADE_SYSTEMS = [ + "v_scale", + "font", +] as const satisfies readonly BoulderGradeSystem[]; +export const ROUTE_GRADE_SYSTEMS = [ "yds", "french", "uiaa", @@ -74,7 +77,23 @@ const ROUTE_SYSTEMS: readonly RouteGradeSystem[] = [ "saxon", "norwegian", "brazilian_crux", -]; +] as const satisfies readonly RouteGradeSystem[]; + +function isBoulderGradeSystem(value: string): value is BoulderGradeSystem { + return value === "v_scale" || value === "font"; +} + +function isRouteGradeSystem(value: string): value is RouteGradeSystem { + return ( + value === "yds" || + value === "french" || + value === "uiaa" || + value === "ewbank" || + value === "saxon" || + value === "norwegian" || + value === "brazilian_crux" + ); +} function sandbagScale(system: ClimbingGradeSystem) { const scale = getScale(systemToSandbagScale[system]); @@ -110,7 +129,23 @@ export function gradeSystemsForClimbType( export function gradeSystemsForClimbType( climbType: ClimbingClimbType, ): readonly ClimbingGradeSystem[] { - return climbType === "boulder" ? BOULDER_SYSTEMS : ROUTE_SYSTEMS; + return climbType === "boulder" ? BOULDER_GRADE_SYSTEMS : ROUTE_GRADE_SYSTEMS; +} + +export function resolveClimbingGradePreference(value: unknown): ClimbingGradePreference { + if ( + typeof value === "object" && + value !== null && + "boulder" in value && + "route" in value && + typeof value.boulder === "string" && + typeof value.route === "string" && + isBoulderGradeSystem(value.boulder) && + isRouteGradeSystem(value.route) + ) { + return { boulder: value.boulder, route: value.route }; + } + return DEFAULT_CLIMBING_GRADE_PREFERENCE; } export function gradeOptionsForSystem(system: ClimbingGradeSystem): readonly string[] { @@ -138,7 +173,9 @@ export function gradeSortValue(grade: string, system: ClimbingGradeSystem): numb const canonical = canonicalGrade(grade, system); if (!canonical) return null; const score = sandbagScale(system).getScore(canonical); - return typeof score === "number" ? score : (score[0] + score[1]) / 2; + if (typeof score === "number") return score; + if (Array.isArray(score) && score.length >= 2) return (score[0] + score[1]) / 2; + return null; } export function convertClimbingGrade(input: { diff --git a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx index deb98c41d7..0569446131 100644 --- a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx +++ b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx @@ -6,19 +6,33 @@ import { useMemo } from "react"; import { trpc } from "../lib/trpc.ts"; import { ClimbingGradeSystemToggle } from "./ClimbingGradeSystemToggle.tsx"; -function createMockLink(): TRPCLink { +type GradeSystemStoryState = "default" | "error" | "loading" | "preference"; + +function createMockLink(state: GradeSystemStoryState): TRPCLink { return () => ({ op }) => createMockObservable( + state, op.path === "settings.get" - ? { key: "climbingGradeSystems", value: { boulder: "font", route: "french" } } + ? { + key: "climbingGradeSystems", + value: state === "preference" ? { boulder: "font", route: "french" } : null, + } : { key: "climbingGradeSystems", value: op.input }, ); } -function createMockObservable(data: unknown): OperationResultObservable { +function createMockObservable( + state: GradeSystemStoryState, + data: unknown, +): OperationResultObservable { const result: OperationResultObservable = { subscribe(observer) { + if (state === "loading") return { unsubscribe: () => {} }; + if (state === "error") { + observer.error?.(new Error("Could not load climbing grade systems.")); + return { unsubscribe: () => {} }; + } observer.next?.({ result: { data } }); observer.complete?.(); return { unsubscribe: () => {} }; @@ -30,9 +44,9 @@ function createMockObservable(data: unknown): OperationResultObservable new QueryClient(), []); - const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink()] }), []); + const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink(state)] }), [state]); return ( @@ -55,5 +69,17 @@ export default meta; type Story = StoryObj; export const FontAndFrench: Story = { - render: () => , + render: () => , +}; + +export const Default: Story = { + render: () => , +}; + +export const Loading: Story = { + render: () => , +}; + +export const ErrorState: Story = { + render: () => , }; diff --git a/packages/web/src/components/ClimbingGradeSystemToggle.tsx b/packages/web/src/components/ClimbingGradeSystemToggle.tsx index 0ac37b2dac..1fad6feefa 100644 --- a/packages/web/src/components/ClimbingGradeSystemToggle.tsx +++ b/packages/web/src/components/ClimbingGradeSystemToggle.tsx @@ -1,9 +1,9 @@ import { type BoulderGradeSystem, type ClimbingGradePreference, - DEFAULT_CLIMBING_GRADE_PREFERENCE, gradeSystemLabel, type RouteGradeSystem, + resolveClimbingGradePreference, } from "@dofek/training/climbing-grades"; import { useEffect, useRef, useState } from "react"; import { captureException } from "../lib/telemetry.ts"; @@ -21,33 +21,12 @@ const ROUTE_SYSTEMS: RouteGradeSystem[] = [ "brazilian_crux", ]; -function preferenceFrom(value: unknown): ClimbingGradePreference { - if ( - typeof value === "object" && - value !== null && - "boulder" in value && - "route" in value && - (value.boulder === "v_scale" || value.boulder === "font") && - (value.route === "yds" || - value.route === "french" || - value.route === "uiaa" || - value.route === "ewbank" || - value.route === "saxon" || - value.route === "norwegian" || - value.route === "brazilian_crux") - ) { - return { boulder: value.boulder, route: value.route }; - } - return DEFAULT_CLIMBING_GRADE_PREFERENCE; -} - export function ClimbingGradeSystemToggle() { const setting = trpc.settings.get.useQuery({ key: SETTINGS_KEY }); const mutation = trpc.settings.set.useMutation(); const utils = trpc.useUtils(); const lastError = useRef(null); const [writeError, setWriteError] = useState(null); - const preference = preferenceFrom(setting.data?.value); useEffect(() => { if (setting.error && lastError.current !== setting.error) { @@ -56,6 +35,15 @@ export function ClimbingGradeSystemToggle() { } }, [setting.error]); + if (!setting.data) { + if (setting.error) { + return

    {setting.error.message}

    ; + } + return

    Loading climbing grade systems…

    ; + } + + const preference = resolveClimbingGradePreference(setting.data.value); + function setPreference(next: ClimbingGradePreference): void { const previous = utils.settings.get.getData({ key: SETTINGS_KEY }); utils.settings.get.setData({ key: SETTINGS_KEY }, { key: SETTINGS_KEY, value: next }); @@ -80,12 +68,14 @@ export function ClimbingGradeSystemToggle() { onChange={(boulder) => setPreference({ ...preference, boulder })} options={BOULDER_SYSTEMS} value={preference.boulder} + disabled={mutation.isPending} /> setPreference({ ...preference, route })} options={ROUTE_SYSTEMS} value={preference.route} + disabled={mutation.isPending} /> {(writeError ?? setting.error?.message) ? (

    @@ -98,11 +88,13 @@ export function ClimbingGradeSystemToggle() { function GradeSystemSelect({ label, + disabled, onChange, options, value, }: { label: string; + disabled: boolean; onChange: (value: T) => void; options: T[]; value: T; @@ -116,6 +108,7 @@ function GradeSystemSelect({ const next = options.find((option) => option === event.target.value); if (next) onChange(next); }} + disabled={disabled} value={value} > {options.map((option) => ( diff --git a/packages/web/src/routes/training/climbing.tsx b/packages/web/src/routes/training/climbing.tsx index c45626ac59..01c27b2c78 100644 --- a/packages/web/src/routes/training/climbing.tsx +++ b/packages/web/src/routes/training/climbing.tsx @@ -1,7 +1,4 @@ -import { - type ClimbingGradePreference, - DEFAULT_CLIMBING_GRADE_PREFERENCE, -} from "@dofek/training/climbing-grades"; +import { resolveClimbingGradePreference } from "@dofek/training/climbing-grades"; import { createFileRoute } from "@tanstack/react-router"; import type { ClimbingSessionSummaryRow } from "dofek-server/types"; import type { Activity } from "../../components/ActivityList.tsx"; @@ -35,26 +32,6 @@ function climbingRangeInput(days: number | null): { days?: number } { return days === null ? {} : { days }; } -function resolveGradePreference(value: unknown): ClimbingGradePreference { - if ( - typeof value === "object" && - value !== null && - "boulder" in value && - "route" in value && - (value.boulder === "v_scale" || value.boulder === "font") && - (value.route === "yds" || - value.route === "french" || - value.route === "uiaa" || - value.route === "ewbank" || - value.route === "saxon" || - value.route === "norwegian" || - value.route === "brazilian_crux") - ) { - return { boulder: value.boulder, route: value.route }; - } - return DEFAULT_CLIMBING_GRADE_PREFERENCE; -} - function climbingSessionColumns( sessionSummaries: ClimbingSessionSummaryRow[], ): Array> { @@ -122,7 +99,9 @@ export function ClimbingTab() { ); const fingerLoadingHistory = trpc.climbing.fingerLoadingHistory.useQuery({ days: 90 }); const gradePreferenceSetting = trpc.settings.get.useQuery({ key: "climbingGradeSystems" }); - const gradePreference = resolveGradePreference(gradePreferenceSetting.data?.value); + const gradePreference = gradePreferenceSetting.data + ? resolveClimbingGradePreference(gradePreferenceSetting.data.value) + : null; const utils = trpc.useUtils(); const fingerLoadingMutation = trpc.climbing.logFingerLoading.useMutation({ meta: { errorReportedLocally: true }, @@ -195,12 +174,18 @@ export function ClimbingTab() { title="Climbing Attempts" subtitle="Record the wall, holds, and outcome of each attempt" > - climbingSessionMutation.mutate(input)} - submitting={climbingSessionMutation.isPending} - /> + {gradePreferenceSetting.error && !gradePreference ? ( + + ) : gradePreference ? ( + climbingSessionMutation.mutate(input)} + submitting={climbingSessionMutation.isPending} + /> + ) : ( + + )}

    From 2a49e31dd3d265ea8b132ae699bb3dbb2cd98923 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 14:02:18 -0700 Subject: [PATCH 41/46] fix(web): type selector error story --- .../web/src/components/ClimbingGradeSystemToggle.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx index 0569446131..85b06bc16a 100644 --- a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx +++ b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { OperationResultObservable, TRPCLink } from "@trpc/client"; +import { type OperationResultObservable, TRPCClientError, type TRPCLink } from "@trpc/client"; import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; import { trpc } from "../lib/trpc.ts"; @@ -30,7 +30,7 @@ function createMockObservable( subscribe(observer) { if (state === "loading") return { unsubscribe: () => {} }; if (state === "error") { - observer.error?.(new Error("Could not load climbing grade systems.")); + observer.error?.(new TRPCClientError("Could not load climbing grade systems.")); return { unsubscribe: () => {} }; } observer.next?.({ result: { data } }); From 062630aac45ef30638a6c637678f6a4b58b04644 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 14:21:06 -0700 Subject: [PATCH 42/46] test(climbing): cover grade preference boundaries --- .../mobile-dashboard-contracts.test.ts | 32 +++++++++++------ packages/training/src/climbing-grades.test.ts | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index 1f3256ac85..ba29afbb69 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -954,18 +954,28 @@ describe("mobileTrainingFixtureSchema", () => { ); }); - it("rejects climbing grade systems from the wrong discipline", () => { - const fixture = validTrainingFixture(); - fixture.data.climbing.gradeProgression.push({ - date: input.endDate, - climbType: "boulder", - gradeSystem: "yds", - grade: "5.10a", - gradeSortValue: 63.5, - }); + it.each([ + ["grade progression", "gradeProgression", "boulder", "yds", "5.10a", 63.5], + ["volume by grade", "volumeByGrade", "route", "font", "6a", 65], + ] as const)( + "rejects %s systems from the wrong discipline", + (_label, target, climbType, gradeSystem, grade, gradeSortValue) => { + const fixture = validTrainingFixture(); + fixture.data.climbing[target].push({ + climbType, + gradeSystem, + grade, + gradeSortValue, + ...(target === "gradeProgression" ? { date: input.endDate } : { attempts: 1, sends: 1 }), + }); - expect(mobileTrainingFixtureSchema.safeParse(fixture).success).toBe(false); - }); + expectIssue( + mobileTrainingFixtureSchema.safeParse(fixture), + ["data", "climbing", target, 0, "gradeSystem"], + "Grade system must match the climb type", + ); + }, + ); it("rejects weekly volume rows that do not start on Monday", () => { const fixture = validTrainingFixture(); diff --git a/packages/training/src/climbing-grades.test.ts b/packages/training/src/climbing-grades.test.ts index 259c376a59..42b0c3ef7f 100644 --- a/packages/training/src/climbing-grades.test.ts +++ b/packages/training/src/climbing-grades.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { convertClimbingGrade, gradeOptionsForSystem, + gradeSortValue, + gradeSystemLabel, gradeSystemsForClimbType, isGradeSystemForClimbType, isValidClimbingGrade, @@ -62,6 +64,21 @@ describe("parseClimbingGrade", () => { ).toMatchObject({ displayGrade: grade, displaySystem: system }); }); + it.each([ + ["v_scale", "boulder", "route"], + ["font", "boulder", "route"], + ["yds", "route", "boulder"], + ["french", "route", "boulder"], + ["uiaa", "route", "boulder"], + ["ewbank", "route", "boulder"], + ["saxon", "route", "boulder"], + ["norwegian", "route", "boulder"], + ["brazilian_crux", "route", "boulder"], + ] as const)("accepts %s only for %s climbs", (system, matchingType, mismatchingType) => { + expect(isGradeSystemForClimbType(system, matchingType)).toBe(true); + expect(isGradeSystemForClimbType(system, mismatchingType)).toBe(false); + }); + it("uses defaults only when a grade preference is malformed or absent", () => { expect(resolveClimbingGradePreference({ boulder: "font", route: "french" })).toEqual({ boulder: "font", @@ -71,6 +88,24 @@ describe("parseClimbingGrade", () => { boulder: "v_scale", route: "yds", }); + expect(resolveClimbingGradePreference(null)).toEqual({ boulder: "v_scale", route: "yds" }); + expect(resolveClimbingGradePreference({ boulder: "font" })).toEqual({ + boulder: "v_scale", + route: "yds", + }); + expect(resolveClimbingGradePreference({ boulder: "v_scale", route: "font" })).toEqual({ + boulder: "v_scale", + route: "yds", + }); + }); + + it("uses Sandbag scores only for valid, scored grades", () => { + expect(gradeSortValue("V4", "v_scale")).toBe(65); + expect(gradeSortValue("5.10", "yds")).toBe(63.5); + expect(gradeSortValue("V-not-a-grade", "v_scale")).toBeNull(); + expect(gradeSystemLabel("uiaa")).toBe( + "International Climbing and Mountaineering Federation (UIAA)", + ); }); it("normalizes V-scale grades and orders them by Sandbag score", () => { const parsedGrades = ["VB", "V0", "V1", "V5", "V10"].map(parseClimbingGrade); From 9774a8d3e17b43ea6b18484fedfc2354ec74d8a8 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 14:34:26 -0700 Subject: [PATCH 43/46] test(climbing): cover displayed grade conversions --- .../mobile-dashboard-contracts.test.ts | 33 ++-- .../repositories/climbing-repository.test.ts | 141 +++++++++++++++++- 2 files changed, 154 insertions(+), 20 deletions(-) diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index ba29afbb69..fa9d567306 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -957,25 +957,22 @@ describe("mobileTrainingFixtureSchema", () => { it.each([ ["grade progression", "gradeProgression", "boulder", "yds", "5.10a", 63.5], ["volume by grade", "volumeByGrade", "route", "font", "6a", 65], - ] as const)( - "rejects %s systems from the wrong discipline", - (_label, target, climbType, gradeSystem, grade, gradeSortValue) => { - const fixture = validTrainingFixture(); - fixture.data.climbing[target].push({ - climbType, - gradeSystem, - grade, - gradeSortValue, - ...(target === "gradeProgression" ? { date: input.endDate } : { attempts: 1, sends: 1 }), - }); + ] as const)("rejects %s systems from the wrong discipline", (_label, target, climbType, gradeSystem, grade, gradeSortValue) => { + const fixture = validTrainingFixture(); + fixture.data.climbing[target].push({ + climbType, + gradeSystem, + grade, + gradeSortValue, + ...(target === "gradeProgression" ? { date: input.endDate } : { attempts: 1, sends: 1 }), + }); - expectIssue( - mobileTrainingFixtureSchema.safeParse(fixture), - ["data", "climbing", target, 0, "gradeSystem"], - "Grade system must match the climb type", - ); - }, - ); + expectIssue( + mobileTrainingFixtureSchema.safeParse(fixture), + ["data", "climbing", target, 0, "gradeSystem"], + "Grade system must match the climb type", + ); + }); it("rejects weekly volume rows that do not start on Monday", () => { const fixture = validTrainingFixture(); diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index cfc0d05d19..e00e32a44f 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { ClimbingGradePreference } from "@dofek/training/climbing-grades"; import { ClimbingActivityEntry, ClimbingGradeProgression, @@ -129,9 +130,18 @@ describe("ClimbingRepository", () => { return { execute }; } - function makeRepository(rows: Record[] = []) { + function makeRepository( + rows: Record[] = [], + gradePreference?: ClimbingGradePreference, + ) { const execute = vi.fn().mockResolvedValue(rows); - const repo = new ClimbingRepository(executeDb(execute), "user-1", "America/Los_Angeles"); + const repo = new ClimbingRepository( + executeDb(execute), + "user-1", + "America/Los_Angeles", + undefined, + gradePreference, + ); return { repo, execute }; } @@ -182,6 +192,45 @@ describe("ClimbingRepository", () => { ]); }); + it("converts boulder and route progression grades to the selected systems", async () => { + const { repo } = makeRepository( + [ + { + session_date: "2026-07-06", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + }, + { + session_date: "2026-07-09", + climb_type: "route", + grade_system: "yds", + grade: "5.10c", + }, + ], + { boulder: "font", route: "french" }, + ); + + const progression = await repo.getGradeProgression(90); + + expect(progression.map((row) => row.toDetail())).toEqual([ + { + date: "2026-07-06", + climbType: "boulder", + gradeSystem: "font", + grade: "6a+/6b+", + gradeSortValue: 65, + }, + { + date: "2026-07-09", + climbType: "route", + gradeSystem: "french", + grade: "6b", + gradeSortValue: 64.5, + }, + ]); + }); + it("queries best sent grades through deduped activity members and excludes unsent entries", async () => { const { repo, execute } = makeRepository([]); @@ -266,6 +315,49 @@ describe("ClimbingRepository", () => { ]); }); + it("converts volume buckets to the selected grade systems", async () => { + const { repo } = makeRepository( + [ + { + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + attempts: 6, + sends: 4, + }, + { + climb_type: "route", + grade_system: "yds", + grade: "5.10c", + attempts: 2, + sends: 1, + }, + ], + { boulder: "font", route: "french" }, + ); + + const volume = await repo.getVolumeByGrade(90); + + expect(volume.map((row) => row.toDetail())).toEqual([ + { + climbType: "route", + gradeSystem: "french", + grade: "6b", + gradeSortValue: 64.5, + attempts: 2, + sends: 1, + }, + { + climbType: "boulder", + gradeSystem: "font", + grade: "6a+/6b+", + gradeSortValue: 65, + attempts: 6, + sends: 4, + }, + ]); + }); + it("queries canonical attempt totals and sent counts", async () => { const { repo, execute } = makeRepository([]); @@ -484,5 +576,50 @@ describe("ClimbingRepository", () => { expect(entries.map((entry) => entry.toDetail().id)).toEqual(["entry-1", "entry-2"]); }); + + it("preserves an unparseable source grade after valid converted entries", async () => { + const { repo } = makeRepository( + [ + { + id: "entry-valid", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + sent: true, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + { + id: "entry-invalid", + climb_type: "boulder", + grade_system: "v_scale", + grade: "not-a-grade", + sent: false, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + ], + { boulder: "font", route: "french" }, + ); + + const entries = await repo.getActivityEntries("activity-1"); + + expect(entries.map((entry) => entry.toDetail())).toMatchObject([ + { id: "entry-valid", gradeSystem: "font", grade: "6a+/6b+" }, + { id: "entry-invalid", gradeSystem: "v_scale", grade: "not-a-grade" }, + ]); + }); }); }); From b8224ced742906c8f3b2cbc3184d4cc3109e172c Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 14:49:01 -0700 Subject: [PATCH 44/46] fix(climbing): address remaining review feedback --- .../2026-08-11-climbing-grade-systems.md | 6 +- packages/mobile/app/settings.styles.ts | 12 +++ packages/mobile/app/settings.tsx | 81 ++---------------- .../ClimbingGradeSystemSettings.test.tsx | 38 +++++++++ .../ClimbingGradeSystemSettings.tsx | 82 +++++++++++++++++++ packages/training/package.json | 3 +- packages/training/src/climbing-grades.test.ts | 13 +++ packages/training/src/climbing-grades.ts | 35 ++------ .../ClimbingGradeSystemToggle.stories.tsx | 17 +++- pnpm-lock.yaml | 3 + 10 files changed, 184 insertions(+), 106 deletions(-) create mode 100644 packages/mobile/components/ClimbingGradeSystemSettings.test.tsx create mode 100644 packages/mobile/components/ClimbingGradeSystemSettings.tsx diff --git a/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md index 0bc96011a0..6e3bae8ea1 100644 --- a/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md +++ b/docs/superpowers/plans/2026-08-11-climbing-grade-systems.md @@ -24,6 +24,7 @@ **Files:** - Modify: `packages/training/package.json` - Modify: `pnpm-lock.yaml` +- Modify: `cspell.json` (add Sandbag and grade-system terms) - Modify: `packages/training/src/climbing-grades.ts` - Modify: `packages/training/src/climbing-grades.test.ts` - Modify: `packages/training/README.md` @@ -38,7 +39,8 @@ expect(gradeSystemsForClimbType("boulder")).toEqual(["v_scale", "font"]); expect(gradeOptionsForSystem("font")).toContain("6a"); expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "font" })) - .toMatchObject({ displaySystem: "font", displayGrade: "6a+/6b+" }); + .toEqual({ displaySystem: "font", displayGrade: "6a+/6b+", sortValue: 65 }); +expect(gradeSortValue("V0", "v_scale")).toBeLessThan(gradeSortValue("V4", "v_scale")); expect(convertClimbingGrade({ grade: "V4", sourceSystem: "v_scale", displaySystem: "yds" })) .toBeNull(); ``` @@ -72,7 +74,7 @@ Expected: PASS. - [ ] **Step 5: Commit the isolated domain change.** ```bash -git add packages/training/package.json packages/training/src/climbing-grades.ts packages/training/src/climbing-grades.test.ts packages/training/README.md pnpm-lock.yaml +git add cspell.json packages/training/package.json packages/training/src/climbing-grades.ts packages/training/src/climbing-grades.test.ts packages/training/README.md pnpm-lock.yaml git commit -m "feat(training): use sandbag for climbing grades" ``` diff --git a/packages/mobile/app/settings.styles.ts b/packages/mobile/app/settings.styles.ts index b939c3ab00..72c1cedf3a 100644 --- a/packages/mobile/app/settings.styles.ts +++ b/packages/mobile/app/settings.styles.ts @@ -186,6 +186,9 @@ export const styles = StyleSheet.create({ flexDirection: "row", gap: 10, }, + gradeSystemList: { + gap: 10, + }, unitButton: { flex: 1, backgroundColor: colors.surface, @@ -195,6 +198,15 @@ export const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 14, }, + gradeSystemButton: { + backgroundColor: colors.surface, + borderRadius: 12, + borderWidth: 1.5, + borderColor: colors.surfaceSecondary, + minHeight: 48, + paddingHorizontal: 16, + paddingVertical: 14, + }, unitButtonSelected: { borderColor: colors.accent, backgroundColor: colors.accentSubtle, diff --git a/packages/mobile/app/settings.tsx b/packages/mobile/app/settings.tsx index 34add09edf..73297a391e 100644 --- a/packages/mobile/app/settings.tsx +++ b/packages/mobile/app/settings.tsx @@ -6,10 +6,7 @@ import { } from "@dofek/auth/auth"; import { formatDateMedium, formatDateTime } from "@dofek/format/format"; import { - type BoulderGradeSystem, type ClimbingGradePreference, - gradeSystemLabel, - type RouteGradeSystem, resolveClimbingGradePreference, } from "@dofek/training/climbing-grades"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -29,6 +26,7 @@ import { View, } from "react-native"; import { AccountErasurePanel } from "../components/AccountErasurePanel"; +import { ClimbingGradeSystemSettings } from "../components/ClimbingGradeSystemSettings"; import { DataExportSection } from "../components/DataExportSection"; import { MedicationDoseEventsPanel } from "../components/MedicationDoseEventsPanel"; import { MedicationRemindersPanel } from "../components/MedicationRemindersPanel"; @@ -64,16 +62,6 @@ const UNIT_OPTIONS: { value: UnitSystem; label: string; description: string }[] { value: "metric", label: "Metric", description: "kg, km, °C" }, { value: "imperial", label: "Imperial", description: "lbs, mi, °F" }, ]; -const BOULDER_GRADE_SYSTEMS: BoulderGradeSystem[] = ["v_scale", "font"]; -const ROUTE_GRADE_SYSTEMS: RouteGradeSystem[] = [ - "yds", - "french", - "uiaa", - "ewbank", - "saxon", - "norwegian", - "brazilian_crux", -]; const SETTINGS_CATEGORIES: readonly { id: SettingsCategory; label: string; @@ -527,67 +515,12 @@ export default function SettingsScreen() { ) : null} {activeCategory === "goals-models" ? ( - - Climbing grades - - Choose the grade systems used for boulders and routes - - {climbingGradeSetting.error && !climbingGradePreference ? ( - {climbingGradeSetting.error.message} - ) : climbingGradePreference ? null : ( - - )} - {climbingGradePreference ? Boulder grades : null} - {climbingGradePreference ? ( - - {BOULDER_GRADE_SYSTEMS.map((value) => { - const selected = climbingGradePreference.boulder === value; - return ( - - handleClimbingGradeChange({ ...climbingGradePreference, boulder: value }) - } - disabled={setSettingMutation.isPending} - accessibilityRole="button" - accessibilityLabel={gradeSystemLabel(value)} - accessibilityState={{ selected, disabled: setSettingMutation.isPending }} - > - - {gradeSystemLabel(value)} - - - ); - })} - - ) : null} - {climbingGradePreference ? Route grades : null} - {climbingGradePreference ? ( - - {ROUTE_GRADE_SYSTEMS.map((value) => { - const selected = climbingGradePreference.route === value; - return ( - - handleClimbingGradeChange({ ...climbingGradePreference, route: value }) - } - disabled={setSettingMutation.isPending} - accessibilityRole="button" - accessibilityLabel={gradeSystemLabel(value)} - accessibilityState={{ selected, disabled: setSettingMutation.isPending }} - > - - {gradeSystemLabel(value)} - - - ); - })} - - ) : null} - + ) : null} {/* ── Health Reports ── */} diff --git a/packages/mobile/components/ClimbingGradeSystemSettings.test.tsx b/packages/mobile/components/ClimbingGradeSystemSettings.test.tsx new file mode 100644 index 0000000000..c1729122b5 --- /dev/null +++ b/packages/mobile/components/ClimbingGradeSystemSettings.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ClimbingGradeSystemSettings } from "./ClimbingGradeSystemSettings"; + +describe("ClimbingGradeSystemSettings", () => { + it("updates the selected route grading system", () => { + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByLabelText("French")); + + expect(onChange).toHaveBeenCalledWith({ boulder: "font", route: "french" }); + }); + + it("disables every system choice while saving", () => { + render( + , + ); + + expect(screen.getByLabelText("Fontainebleau").getAttribute("aria-disabled")).toBe("true"); + expect(screen.getByLabelText("French").getAttribute("aria-disabled")).toBe("true"); + }); +}); diff --git a/packages/mobile/components/ClimbingGradeSystemSettings.tsx b/packages/mobile/components/ClimbingGradeSystemSettings.tsx new file mode 100644 index 0000000000..bcdf31fb1c --- /dev/null +++ b/packages/mobile/components/ClimbingGradeSystemSettings.tsx @@ -0,0 +1,82 @@ +import { + BOULDER_GRADE_SYSTEMS, + type ClimbingGradePreference, + gradeSystemLabel, + ROUTE_GRADE_SYSTEMS, +} from "@dofek/training/climbing-grades"; +import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native"; +import { styles } from "../app/settings.styles"; +import { colors } from "../theme"; + +interface ClimbingGradeSystemSettingsProps { + errorMessage: string | null; + onChange: (preference: ClimbingGradePreference) => void; + preference: ClimbingGradePreference | null; + saving: boolean; +} + +export function ClimbingGradeSystemSettings({ + errorMessage, + onChange, + preference, + saving, +}: ClimbingGradeSystemSettingsProps) { + return ( + + Climbing grades + + Choose the grade systems used for boulders and routes + + {errorMessage && !preference ? ( + {errorMessage} + ) : null} + {preference ? null : } + {preference ? Boulder grades : null} + {preference ? ( + + {BOULDER_GRADE_SYSTEMS.map((value) => { + const selected = preference.boulder === value; + return ( + onChange({ ...preference, boulder: value })} + disabled={saving} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: saving }} + > + + {gradeSystemLabel(value)} + + + ); + })} + + ) : null} + {preference ? Route grades : null} + {preference ? ( + + {ROUTE_GRADE_SYSTEMS.map((value) => { + const selected = preference.route === value; + return ( + onChange({ ...preference, route: value })} + disabled={saving} + accessibilityRole="button" + accessibilityLabel={gradeSystemLabel(value)} + accessibilityState={{ selected, disabled: saving }} + > + + {gradeSystemLabel(value)} + + + ); + })} + + ) : null} + + ); +} diff --git a/packages/training/package.json b/packages/training/package.json index b70291b530..677cfe58d6 100644 --- a/packages/training/package.json +++ b/packages/training/package.json @@ -44,7 +44,8 @@ "dependencies": { "@dofek/scoring": "workspace:*", "@dofek/zones": "workspace:*", - "@openbeta/sandbag": "0.0.55" + "@openbeta/sandbag": "0.0.55", + "zod": "4.4.3" }, "scripts": { "build": "tsc", diff --git a/packages/training/src/climbing-grades.test.ts b/packages/training/src/climbing-grades.test.ts index 42b0c3ef7f..f2702cab75 100644 --- a/packages/training/src/climbing-grades.test.ts +++ b/packages/training/src/climbing-grades.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + climbingGradePreferenceSchema, convertClimbingGrade, gradeOptionsForSystem, gradeSortValue, @@ -99,6 +100,18 @@ describe("parseClimbingGrade", () => { }); }); + it("rejects persisted preferences with grade systems from the wrong disciplines", () => { + expect( + climbingGradePreferenceSchema.safeParse({ boulder: "font", route: "french" }).success, + ).toBe(true); + expect( + climbingGradePreferenceSchema.safeParse({ boulder: "french", route: "yds" }).success, + ).toBe(false); + expect( + climbingGradePreferenceSchema.safeParse({ boulder: "font", route: "font" }).success, + ).toBe(false); + }); + it("uses Sandbag scores only for valid, scored grades", () => { expect(gradeSortValue("V4", "v_scale")).toBe(65); expect(gradeSortValue("5.10", "yds")).toBe(63.5); diff --git a/packages/training/src/climbing-grades.ts b/packages/training/src/climbing-grades.ts index 1aec69562f..990fb6475f 100644 --- a/packages/training/src/climbing-grades.ts +++ b/packages/training/src/climbing-grades.ts @@ -1,6 +1,7 @@ /// import { convertGrade, GradeScales, getScale } from "@openbeta/sandbag"; +import { z } from "zod"; export const CLIMBING_GRADE_SYSTEMS = [ "v_scale", @@ -79,21 +80,10 @@ export const ROUTE_GRADE_SYSTEMS = [ "brazilian_crux", ] as const satisfies readonly RouteGradeSystem[]; -function isBoulderGradeSystem(value: string): value is BoulderGradeSystem { - return value === "v_scale" || value === "font"; -} - -function isRouteGradeSystem(value: string): value is RouteGradeSystem { - return ( - value === "yds" || - value === "french" || - value === "uiaa" || - value === "ewbank" || - value === "saxon" || - value === "norwegian" || - value === "brazilian_crux" - ); -} +export const climbingGradePreferenceSchema = z.object({ + boulder: z.enum(BOULDER_GRADE_SYSTEMS), + route: z.enum(ROUTE_GRADE_SYSTEMS), +}); function sandbagScale(system: ClimbingGradeSystem) { const scale = getScale(systemToSandbagScale[system]); @@ -133,19 +123,8 @@ export function gradeSystemsForClimbType( } export function resolveClimbingGradePreference(value: unknown): ClimbingGradePreference { - if ( - typeof value === "object" && - value !== null && - "boulder" in value && - "route" in value && - typeof value.boulder === "string" && - typeof value.route === "string" && - isBoulderGradeSystem(value.boulder) && - isRouteGradeSystem(value.route) - ) { - return { boulder: value.boulder, route: value.route }; - } - return DEFAULT_CLIMBING_GRADE_PREFERENCE; + const parsed = climbingGradePreferenceSchema.safeParse(value); + return parsed.success ? parsed.data : DEFAULT_CLIMBING_GRADE_PREFERENCE; } export function gradeOptionsForSystem(system: ClimbingGradeSystem): readonly string[] { diff --git a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx index 85b06bc16a..390aaf4057 100644 --- a/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx +++ b/packages/web/src/components/ClimbingGradeSystemToggle.stories.tsx @@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type OperationResultObservable, TRPCClientError, type TRPCLink } from "@trpc/client"; import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; +import { expect, within } from "storybook/test"; import { trpc } from "../lib/trpc.ts"; import { ClimbingGradeSystemToggle } from "./ClimbingGradeSystemToggle.tsx"; -type GradeSystemStoryState = "default" | "error" | "loading" | "preference"; +type GradeSystemStoryState = "default" | "error" | "loading" | "preference" | "saving"; function createMockLink(state: GradeSystemStoryState): TRPCLink { return () => @@ -19,12 +20,14 @@ function createMockLink(state: GradeSystemStoryState): TRPCLink { value: state === "preference" ? { boulder: "font", route: "french" } : null, } : { key: "climbingGradeSystems", value: op.input }, + state === "saving" && op.path === "settings.set", ); } function createMockObservable( state: GradeSystemStoryState, data: unknown, + pending = false, ): OperationResultObservable { const result: OperationResultObservable = { subscribe(observer) { @@ -33,6 +36,7 @@ function createMockObservable( observer.error?.(new TRPCClientError("Could not load climbing grade systems.")); return { unsubscribe: () => {} }; } + if (pending) return { unsubscribe: () => {} }; observer.next?.({ result: { data } }); observer.complete?.(); return { unsubscribe: () => {} }; @@ -83,3 +87,14 @@ export const Loading: Story = { export const ErrorState: Story = { render: () => , }; + +export const Saving: Story = { + render: () => , + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await userEvent.selectOptions(canvas.getByLabelText("Boulder grade system"), "font"); + + await expect(canvas.getByLabelText("Boulder grade system")).toBeDisabled(); + await expect(canvas.getByLabelText("Route grade system")).toBeDisabled(); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e16c46b860..cc327468a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,6 +841,9 @@ importers: '@openbeta/sandbag': specifier: 0.0.55 version: 0.0.55 + zod: + specifier: 4.4.3 + version: 4.4.3 packages/trainingpeaks-connect: dependencies: From 2ca12673abd7e8f092d6896c7edfa9a1f2159818 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 14:57:44 -0700 Subject: [PATCH 45/46] fix(ci): organize climbing repository test imports --- packages/server/src/repositories/climbing-repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index e00e32a44f..b46babf62c 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; import type { ClimbingGradePreference } from "@dofek/training/climbing-grades"; +import { describe, expect, it, vi } from "vitest"; import { ClimbingActivityEntry, ClimbingGradeProgression, From 3f44f0892c6f68187d1f9c273b6627159fb9aaeb Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Tue, 11 Aug 2026 15:09:47 -0700 Subject: [PATCH 46/46] test(climbing): cover repository grade aggregation branches --- .../repositories/climbing-repository.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index b46babf62c..f986782e51 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -231,6 +231,48 @@ describe("ClimbingRepository", () => { ]); }); + it("keeps the first equal grade, replaces it with a harder grade, skips invalid grades, and orders session types", async () => { + const { repo } = makeRepository([ + { + session_date: "2026-07-06", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V3", + }, + { + session_date: "2026-07-06", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V3", + }, + { + session_date: "2026-07-06", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + }, + { + session_date: "2026-07-06", + climb_type: "boulder", + grade_system: "v_scale", + grade: "not-a-grade", + }, + { + session_date: "2026-07-06", + climb_type: "route", + grade_system: "yds", + grade: "5.10c", + }, + ]); + + const progression = await repo.getGradeProgression(90); + + expect(progression.map((row) => row.toDetail())).toEqual([ + expect.objectContaining({ climbType: "boulder", grade: "V4", gradeSortValue: 65 }), + expect.objectContaining({ climbType: "route", grade: "5.10c", gradeSortValue: 64.5 }), + ]); + }); + it("queries best sent grades through deduped activity members and excludes unsent entries", async () => { const { repo, execute } = makeRepository([]); @@ -358,6 +400,48 @@ describe("ClimbingRepository", () => { ]); }); + it("merges source grades that convert to the same display bucket and skips invalid grades", async () => { + const { repo } = makeRepository( + [ + { + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + attempts: 3, + sends: 1, + }, + { + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + attempts: 2, + sends: 2, + }, + { + climb_type: "boulder", + grade_system: "v_scale", + grade: "not-a-grade", + attempts: 9, + sends: 9, + }, + ], + { boulder: "font", route: "french" }, + ); + + const volume = await repo.getVolumeByGrade(90); + + expect(volume.map((row) => row.toDetail())).toEqual([ + { + climbType: "boulder", + gradeSystem: "font", + grade: "6a+/6b+", + gradeSortValue: 65, + attempts: 5, + sends: 3, + }, + ]); + }); + it("queries canonical attempt totals and sent counts", async () => { const { repo, execute } = makeRepository([]); @@ -479,6 +563,78 @@ describe("ClimbingRepository", () => { expect(summary?.toDetail().locationName).toBe("Pacific Pipe"); }); + + it("preserves the first location, counts only sent entries, and selects each climb type's hardest sent grade", async () => { + const { repo } = makeRepository([ + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: "First gym", + attempt_count: 2, + sent: true, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V3", + }, + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: "Second gym", + attempt_count: 3, + sent: false, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V8", + }, + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: null, + attempt_count: 4, + sent: true, + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + }, + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: null, + attempt_count: 5, + sent: true, + climb_type: "route", + grade_system: "yds", + grade: "5.10c", + }, + { + activity_id: "activity-1", + session_date: "2026-07-09", + name: "Climbing session", + location_name: null, + attempt_count: 6, + sent: true, + climb_type: "route", + grade_system: "yds", + grade: "5.11a", + }, + ]); + + const [summary] = await repo.getSessionSummaries(90); + + expect(summary?.toDetail()).toMatchObject({ + locationName: "First gym", + attempts: 20, + sends: 4, + hardestBoulderGrade: "V4", + hardestBoulderGradeSortValue: 65, + hardestRouteGrade: "5.11a", + hardestRouteGradeSortValue: 67.5, + }); + }); }); describe("getActivityEntries", () => { @@ -621,5 +777,63 @@ describe("ClimbingRepository", () => { { id: "entry-invalid", gradeSystem: "v_scale", grade: "not-a-grade" }, ]); }); + + it("orders valid grades from hardest to easiest and uses entry IDs to break ties", async () => { + const { repo } = makeRepository([ + { + id: "entry-b", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + sent: true, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + { + id: "entry-a", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V4", + sent: true, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + { + id: "entry-c", + climb_type: "boulder", + grade_system: "v_scale", + grade: "V3", + sent: true, + attempt_count: 1, + attempts: [], + ascent_type: null, + hold_type: null, + route_name: null, + location_name: null, + source_name: "Kaya", + wall_angle_degrees: null, + }, + ]); + + const entries = await repo.getActivityEntries("activity-1"); + + expect(entries.map((entry) => entry.toDetail().id)).toEqual([ + "entry-a", + "entry-b", + "entry-c", + ]); + }); }); });