diff --git a/packages/mobile/app/providers/index.tsx b/packages/mobile/app/providers/index.tsx index d8788f878d..49f76901aa 100644 --- a/packages/mobile/app/providers/index.tsx +++ b/packages/mobile/app/providers/index.tsx @@ -433,13 +433,13 @@ export default function ProvidersScreen() { ); const handleSyncProvider = useCallback( - async (providerId: string) => { + async (providerId: string, fullSync = false) => { setSyncingProviders((prev) => new Set(prev).add(providerId)); setAnySyncing(true); try { const result = await syncMutation.mutateAsync({ providerId, - sinceDays: ROUTINE_SYNC_DAYS, + sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS, }); const providerResult = result.providerResults?.find( (entry) => entry.providerId === providerId, @@ -853,6 +853,7 @@ export default function ProvidersScreen() { syncing={syncingProviders.has(provider.id)} syncProgress={syncProgress[provider.id]} onSync={() => handleSyncProvider(provider.id)} + onFullSync={() => handleSyncProvider(provider.id, true)} onConnect={() => handleConnect(provider)} onPress={() => router.push(`/providers/${provider.id}`)} /> diff --git a/packages/mobile/app/providers/provider-card.tsx b/packages/mobile/app/providers/provider-card.tsx index 5515ce6ce3..f575499e4c 100644 --- a/packages/mobile/app/providers/provider-card.tsx +++ b/packages/mobile/app/providers/provider-card.tsx @@ -86,6 +86,7 @@ export function ProviderCard({ importing = false, syncProgress, onSync, + onFullSync, onConnect, onImport, onPress, @@ -96,6 +97,7 @@ export function ProviderCard({ importing?: boolean; syncProgress: { percentage?: number; message?: string; failedCount?: number } | undefined; onSync: () => void; + onFullSync?: () => void; onConnect: () => void; onImport?: () => void; onPress: () => void; @@ -187,6 +189,19 @@ export function ProviderCard({ ) : ( Never synced ))} + {canRunManualSync && + provider.authStatus === "connected" && + onFullSync !== undefined && + !syncing && ( + + Full sync + + )} )} diff --git a/packages/mobile/lib/background-health-kit-sync.test.ts b/packages/mobile/lib/background-health-kit-sync.test.ts index 6b09b6728e..420ad73004 100644 --- a/packages/mobile/lib/background-health-kit-sync.test.ts +++ b/packages/mobile/lib/background-health-kit-sync.test.ts @@ -356,6 +356,78 @@ describe("initBackgroundHealthKitSync", () => { vi.useRealTimers(); }); + it("does not report TRPCClientError background fetch timeouts to Sentry (DOFEK-MOBILE-19)", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + await initBackgroundHealthKitSync(client); + await vi.runAllTimersAsync(); + mockCaptureException.mockClear(); + vi.mocked(queryDailyStatistics).mockResolvedValueOnce([{ date: "2026-03-22", value: 1_000 }]); + const timeoutError = new Error("fetch failed: UnexpectedException: The request timed out."); + client.healthKitSync.pushQuantitySamples.mutate.mockRejectedValueOnce( + new Error("TRPCClientError", { cause: timeoutError }), + ); + + const listener = mockAddSampleUpdateListener.mock.calls[0][0]; + listener({ + typeIdentifier: "HKQuantityTypeIdentifierStepCount", + updateId: "update-1", + }); + + await vi.advanceTimersByTimeAsync(5000); + await vi.runAllTimersAsync(); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + "bg-healthkit-sync", + "Background HealthKit upload timed out; retrying on next delivery", + ); + expect(mockCompleteObserverUpdates).toHaveBeenCalledWith(["update-1"], false); + vi.useRealTimers(); + }); + + it("does not report observer sync result errors that are only transient timeouts", async () => { + vi.useFakeTimers(); + const client = createMockClient(); + await initBackgroundHealthKitSync(client); + await vi.runAllTimersAsync(); + mockCaptureException.mockClear(); + vi.mocked(queryWorkouts).mockResolvedValueOnce([ + { + uuid: "workout-1", + activityType: 1, + startDate: "2026-03-22T10:00:00Z", + endDate: "2026-03-22T11:00:00Z", + duration: 3600, + totalDistance: 10000, + sourceName: "Apple Watch", + }, + ]); + vi.mocked(queryWorkoutRoutes).mockResolvedValueOnce([ + { latitude: 37.77, longitude: -122.42, timestamp: "2026-03-22T10:00:00Z" }, + ]); + client.healthKitSync.pushWorkoutRoutes.mutate.mockRejectedValueOnce( + new Error("fetch failed: UnexpectedException: The request timed out."), + ); + + const listener = mockAddSampleUpdateListener.mock.calls[0][0]; + listener({ + typeIdentifier: "HKWorkoutTypeIdentifier", + updateId: "update-1", + }); + + await vi.advanceTimersByTimeAsync(5000); + await vi.runAllTimersAsync(); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + "bg-healthkit-sync", + "Background HealthKit upload timed out; retrying on next delivery", + ); + expect(mockCompleteObserverUpdates).toHaveBeenCalledWith(["update-1"], false); + vi.useRealTimers(); + }); + it("marks native observer sync lifecycle while draining deliveries (DOFEK-MOBILE-1C)", async () => { const client = createMockClient(); await initBackgroundHealthKitSync(client); diff --git a/packages/mobile/lib/background-health-kit-sync.ts b/packages/mobile/lib/background-health-kit-sync.ts index 9acae4bb1d..5f6f1dd681 100644 --- a/packages/mobile/lib/background-health-kit-sync.ts +++ b/packages/mobile/lib/background-health-kit-sync.ts @@ -10,6 +10,7 @@ import { AppleHealthAuthorizationService, AppleHealthSyncService } from "./apple import { isBackgroundHealthKitTransientNetworkError, isHealthKitDatabaseInaccessible, + isTransientNetworkErrorMessage, } from "./health-kit-errors"; import { BACKGROUND_HEALTH_KIT_TYPES, @@ -103,13 +104,21 @@ async function performHealthKitSync( } if (result.errors.length > 0) { + const actionableErrors = result.errors.filter( + (message) => !isTransientNetworkErrorMessage(message), + ); + if (actionableErrors.length === 0) { + stageTelemetry.complete("failed"); + logger.info(TAG, "Background HealthKit upload timed out; retrying on next delivery"); + return false; + } stageTelemetry.complete("failed"); const error = new Error( - `HealthKit observer sync completed with ${result.errors.length} error(s): ${result.errors.join("; ")}`, + `HealthKit observer sync completed with ${actionableErrors.length} error(s): ${actionableErrors.join("; ")}`, ); logger.warn(TAG, error.message); captureException(error, { - errorCount: result.errors.length, + errorCount: actionableErrors.length, source: TAG, }); return false; diff --git a/packages/mobile/lib/health-kit-errors.test.ts b/packages/mobile/lib/health-kit-errors.test.ts new file mode 100644 index 0000000000..b4e8b339ac --- /dev/null +++ b/packages/mobile/lib/health-kit-errors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + isBackgroundHealthKitTransientNetworkError, + isHealthKitSentrySource, + isTransientNetworkErrorMessage, +} from "./health-kit-errors"; + +describe("health-kit-errors", () => { + it("detects transient background fetch timeout messages", () => { + expect( + isTransientNetworkErrorMessage("fetch failed: UnexpectedException: The request timed out."), + ).toBe(true); + expect( + isTransientNetworkErrorMessage( + "Push workout routes: fetch failed: UnexpectedException: The request timed out.", + ), + ).toBe(true); + expect(isTransientNetworkErrorMessage("network unreachable")).toBe(false); + expect(isTransientNetworkErrorMessage("fetch failed: connection reset")).toBe(false); + expect(isTransientNetworkErrorMessage("request timeout")).toBe(false); + }); + + it("detects transient network errors on Error instances and causes (DOFEK-MOBILE-19)", () => { + const timeoutError = new Error("fetch failed: UnexpectedException: The request timed out."); + const trpcError = new Error("TRPCClientError", { cause: timeoutError }); + + expect(isBackgroundHealthKitTransientNetworkError(timeoutError)).toBe(true); + expect(isBackgroundHealthKitTransientNetworkError(trpcError)).toBe(true); + expect(isBackgroundHealthKitTransientNetworkError(new Error("server error"))).toBe(false); + }); + + it("identifies HealthKit Sentry sources used for scoped timeout filtering", () => { + expect(isHealthKitSentrySource("bg-healthkit-sync")).toBe(true); + expect(isHealthKitSentrySource("health-kit-workout-route-push")).toBe(true); + expect(isHealthKitSentrySource("auto-sync-providers")).toBe(false); + expect(isHealthKitSentrySource(undefined)).toBe(false); + }); +}); diff --git a/packages/mobile/lib/health-kit-errors.ts b/packages/mobile/lib/health-kit-errors.ts index 15e9049728..6bc50a8eef 100644 --- a/packages/mobile/lib/health-kit-errors.ts +++ b/packages/mobile/lib/health-kit-errors.ts @@ -12,11 +12,29 @@ export function isHealthKitDatabaseInaccessible(error: unknown): boolean { ); } +export function isTransientNetworkErrorMessage(message: string): boolean { + // Match the React Native fetch timeout shape seen in DOFEK-MOBILE-19, including + // when the message is prefixed by sync-stage labels or TRPC wrappers. + return /fetch failed.*the request timed out/i.test(message); +} + export function isBackgroundHealthKitTransientNetworkError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); + if (error instanceof Error) { + if (isTransientNetworkErrorMessage(error.message)) { + return true; + } + if (error.cause !== undefined) { + return isBackgroundHealthKitTransientNetworkError(error.cause); + } + return false; + } + return isTransientNetworkErrorMessage(String(error)); +} + +export const HEALTHKIT_BACKGROUND_SENTRY_SOURCE = "bg-healthkit-sync"; + +export function isHealthKitSentrySource(source: string | undefined): boolean { return ( - normalized.includes("fetch failed") && - (normalized.includes("timed out") || normalized.includes("timeout")) + source === HEALTHKIT_BACKGROUND_SENTRY_SOURCE || (source?.startsWith("health-kit-") ?? false) ); } diff --git a/packages/mobile/lib/health-kit-sync.test.ts b/packages/mobile/lib/health-kit-sync.test.ts index 7527ca3643..390056ca94 100644 --- a/packages/mobile/lib/health-kit-sync.test.ts +++ b/packages/mobile/lib/health-kit-sync.test.ts @@ -402,6 +402,27 @@ describe("syncHealthKitToServer", () => { expect(client.healthKitSync.pushWorkoutRoutes.mutate).not.toHaveBeenCalled(); }); + it("does not report transient TRPC timeout wrappers during route push", async () => { + const client = createMockClient(); + const healthKit = createMockHealthKit(); + healthKit.queryWorkoutRoutes.mockResolvedValue([ + { date: "2026-03-21T07:00:00Z", lat: 40.7128, lng: -74.006 }, + ]); + const timeoutCause = new Error("fetch failed: UnexpectedException: The request timed out."); + const trpcTimeoutError = new Error("TRPCClientError", { cause: timeoutCause }); + client.healthKitSync.pushWorkoutRoutes.mutate.mockRejectedValue(trpcTimeoutError); + vi.mocked(captureException).mockClear(); + + const result = await syncHealthKitToServer({ + trpcClient: client, + healthKit, + syncRangeDays: 1, + }); + + expect(result.errors.some((error) => error.includes("Push workout routes"))).toBe(true); + expect(captureException).not.toHaveBeenCalled(); + }); + it("records route push errors as non-fatal", async () => { const client = createMockClient(); const healthKit = createMockHealthKit(); diff --git a/packages/mobile/lib/health-kit-sync.ts b/packages/mobile/lib/health-kit-sync.ts index 69364bb736..ff8e1e0ccf 100644 --- a/packages/mobile/lib/health-kit-sync.ts +++ b/packages/mobile/lib/health-kit-sync.ts @@ -5,7 +5,10 @@ import type { SleepSample, WorkoutSample, } from "../modules/health-kit"; -import { isHealthKitDatabaseInaccessible } from "./health-kit-errors"; +import { + isBackgroundHealthKitTransientNetworkError, + isHealthKitDatabaseInaccessible, +} from "./health-kit-errors"; import { captureException } from "./telemetry"; // Additive types use HKStatisticsCollectionQuery for proper source deduplication. @@ -79,6 +82,33 @@ function normalizeWorkout(workout: WorkoutSample): WorkoutSample { }; } +interface WorkoutRouteErrorContext { + errors: string[]; + errorLabel: string; + source: string; + captureContext?: Record; + suppressTransientNetworkErrors?: boolean; +} + +function handleWorkoutRouteError( + error: unknown, + context: WorkoutRouteErrorContext, +): "locked" | undefined { + if (isHealthKitDatabaseInaccessible(error)) { + return "locked"; + } + const shouldReport = + !context.suppressTransientNetworkErrors || !isBackgroundHealthKitTransientNetworkError(error); + if (shouldReport) { + captureException(error, { + source: context.source, + ...context.captureContext, + }); + } + const message = error instanceof Error ? error.message : String(error); + context.errors.push(`${context.errorLabel}: ${message}`); +} + function isAuthorizationNotDetermined(error: unknown): boolean { if (typeof error !== "object" || error === null || !("code" in error)) { return false; @@ -327,12 +357,12 @@ export async function syncHealthKitToServer(options: SyncOptions): Promise { expect(mocks.mockInit).toHaveBeenCalledWith({ dsn: "https://key@sentry.example/789", debug: true, + beforeSend: expect.any(Function), tracesSampler: expect.any(Function), }); const options = mocks.mockInit.mock.calls[0]?.[0]; + const beforeSend = options?.beforeSend; + const timeoutError = new Error("fetch failed: UnexpectedException: The request timed out."); + expect(beforeSend?.({ event_id: "event-1" }, { originalException: timeoutError })).toEqual({ + event_id: "event-1", + }); + expect( + beforeSend?.( + { event_id: "event-2", tags: { source: "bg-healthkit-sync" } }, + { originalException: timeoutError }, + ), + ).toBeNull(); + expect( + beforeSend?.( + { event_id: "event-3", tags: { source: "health-kit-workout-route-push" } }, + { originalException: timeoutError }, + ), + ).toBeNull(); + expect( + beforeSend?.( + { event_id: "event-4", tags: { source: "auto-sync-providers" } }, + { originalException: timeoutError }, + ), + ).toEqual({ event_id: "event-4", tags: { source: "auto-sync-providers" } }); + expect( + beforeSend?.( + { event_id: "event-5" }, + { originalException: new Error("unexpected server failure") }, + ), + ).toEqual({ event_id: "event-5" }); const tracesSampler = options?.tracesSampler; expect(tracesSampler?.({ name: "App Start", inheritOrSampleWith: vi.fn() })).toBe(1); expect(tracesSampler?.({ name: "Mobile Startup", inheritOrSampleWith: vi.fn() })).toBe(1); diff --git a/packages/mobile/lib/telemetry.ts b/packages/mobile/lib/telemetry.ts index 356a320b16..1e251a5b9b 100644 --- a/packages/mobile/lib/telemetry.ts +++ b/packages/mobile/lib/telemetry.ts @@ -4,6 +4,10 @@ import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import * as Sentry from "@sentry/react-native"; +import { + isBackgroundHealthKitTransientNetworkError, + isHealthKitSentrySource, +} from "./health-kit-errors"; const SENTRY_DSN: string | undefined = process.env.EXPO_PUBLIC_SENTRY_DSN; const OTEL_ENDPOINT: string | undefined = process.env.EXPO_PUBLIC_OTEL_ENDPOINT; @@ -63,6 +67,23 @@ export function initTelemetry() { Sentry.init({ dsn: SENTRY_DSN, debug: __DEV__, + beforeSend(event, hint) { + const error = hint.originalException; + if (!isBackgroundHealthKitTransientNetworkError(error)) { + return event; + } + + const source = + typeof event.tags?.source === "string" + ? event.tags.source + : typeof event.extra?.source === "string" + ? event.extra.source + : undefined; + if (isHealthKitSentrySource(source)) { + return null; + } + return event; + }, tracesSampler: ({ name, inheritOrSampleWith }) => name === "App Start" || name === "Mobile Startup" ? 1 : inheritOrSampleWith(0), }); diff --git a/packages/mobile/modules/health-kit/ios/HealthKitModule.swift b/packages/mobile/modules/health-kit/ios/HealthKitModule.swift index 66d62f9643..0d096036ed 100644 --- a/packages/mobile/modules/health-kit/ios/HealthKitModule.swift +++ b/packages/mobile/modules/health-kit/ios/HealthKitModule.swift @@ -41,15 +41,32 @@ public class HealthKitModule: Module { private let hasEverAuthorizedKey = "healthkit_has_ever_authorized" private var observerSyncInProgress = false private let observerStateLock = NSLock() - // Lazy so the expiration closure can capture self after stored properties finish - // initializing. Swift serializes lazy initialization, and first access only happens - // from observer callbacks / complete* after the module is fully constructed. - private lazy var observerUpdateCoordinator = HealthKitObserverUpdateCoordinator( - timeout: 25, - reportExpiration: { [weak self] expiration in - self?.handleObserverUpdateExpiration(expiration) + private let observerUpdateCoordinatorLock = NSLock() + private var observerUpdateCoordinator: HealthKitObserverUpdateCoordinator? + + private func makeObserverUpdateCoordinator() -> HealthKitObserverUpdateCoordinator { + HealthKitObserverUpdateCoordinator( + timeout: 25, + reportExpiration: { [weak self] expiration in + self?.handleObserverUpdateExpiration(expiration) + } + ) + } + + private func observerUpdateCoordinatorInstance() -> HealthKitObserverUpdateCoordinator { + observerUpdateCoordinatorLock.lock() + defer { observerUpdateCoordinatorLock.unlock() } + if let existing = observerUpdateCoordinator { + return existing } - ) + let created = makeObserverUpdateCoordinator() + observerUpdateCoordinator = created + return created + } + + private func ensureObserverUpdateCoordinatorInitialized() { + observerUpdateCoordinatorInstance() + } private var observerQueries: [HKObserverQuery] = [] private func markObserverSyncInProgress() { @@ -65,7 +82,7 @@ public class HealthKitModule: Module { defer { observerStateLock.unlock() } if inProgress { observerSyncInProgress = true - } else if !observerUpdateCoordinator.hasPendingUpdates { + } else if !observerUpdateCoordinatorInstance().hasPendingUpdates { observerSyncInProgress = false } } @@ -73,7 +90,7 @@ public class HealthKitModule: Module { private func handleObserverUpdateExpiration(_ expiration: HealthKitObserverUpdateExpiration) { observerStateLock.lock() let syncInProgress = observerSyncInProgress - let hasPendingUpdates = observerUpdateCoordinator.hasPendingUpdates + let hasPendingUpdates = observerUpdateCoordinatorInstance().hasPendingUpdates if !hasPendingUpdates { observerSyncInProgress = false } @@ -99,7 +116,7 @@ public class HealthKitModule: Module { healthStore.stop(query) } observerQueries.removeAll() - return observerUpdateCoordinator.completeAll() + return observerUpdateCoordinatorInstance().completeAll() } private func rejectPromise(_ promise: Promise, code: String, reason: String) { @@ -132,6 +149,10 @@ public class HealthKitModule: Module { _ = self.stopBackgroundObservers() } + OnCreate { + self.ensureObserverUpdateCoordinatorInitialized() + } + Function("isAvailable") { return HKHealthStore.isHealthDataAvailable() } @@ -840,6 +861,9 @@ public class HealthKitModule: Module { return } + // Initialize before observer callbacks or JS completion paths can access concurrently. + self.ensureObserverUpdateCoordinatorInitialized() + // Re-registration must settle every callback owned by the old queries. self.stopBackgroundObservers() @@ -873,7 +897,7 @@ public class HealthKitModule: Module { return } - let updateId = self.observerUpdateCoordinator.register( + let updateId = self.observerUpdateCoordinatorInstance().register( typeIdentifier: sampleType.identifier, completion: completionHandler ) @@ -940,7 +964,7 @@ public class HealthKitModule: Module { updateIds.count ) } - return self.observerUpdateCoordinator.complete(updateIds: updateIds) + return self.observerUpdateCoordinatorInstance().complete(updateIds: updateIds) } Function("teardownBackgroundObservers") { () -> Int in diff --git a/packages/server/package.json b/packages/server/package.json index 02fcfee35b..197d8a3716 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,6 +7,7 @@ "./router": "./src/router.ts", "./types": "./src/types.ts", "./baseline-relative-metrics": "./src/contracts/baseline-relative-metrics.ts", + "./report-empty-state": "./src/contracts/report-empty-state.ts", "./health-report-share-expiry": "./src/health-report-share-expiry.ts", "./mobile-dashboard-contracts": "./src/contracts/mobile-dashboard-contracts.ts", "./sleep-need-contract": "./src/contracts/sleep-need-contract.ts" diff --git a/packages/web/src/components/MonthlyReportContent.stories.tsx b/packages/web/src/components/MonthlyReportContent.stories.tsx index 2a76d2f4dd..1fabdbcbbf 100644 --- a/packages/web/src/components/MonthlyReportContent.stories.tsx +++ b/packages/web/src/components/MonthlyReportContent.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createReportEmptyState } from "dofek-server/report-empty-state"; import { MonthlyReportContent } from "./MonthlyReportContent.tsx"; -import { monthlyReportEmptyStateFixture } from "./report-empty-state-fixtures.ts"; const meta = { title: "Reports/MonthlyReportContent", @@ -35,7 +35,7 @@ const meta = { "These period averages can show co-movement, but they cannot establish cause and effect.", ], }, - emptyState: monthlyReportEmptyStateFixture, + emptyState: createReportEmptyState("monthly"), }, }, } satisfies Meta; @@ -52,7 +52,7 @@ export const Empty: Story = { current: null, history: [], decisionSupport: null, - emptyState: monthlyReportEmptyStateFixture, + emptyState: createReportEmptyState("monthly"), }, }, }; diff --git a/packages/web/src/components/MonthlyReportContent.test.tsx b/packages/web/src/components/MonthlyReportContent.test.tsx index 17586c14bb..c745b34460 100644 --- a/packages/web/src/components/MonthlyReportContent.test.tsx +++ b/packages/web/src/components/MonthlyReportContent.test.tsx @@ -2,9 +2,9 @@ import { textColors } from "@dofek/scoring/colors"; import { render, screen } from "@testing-library/react"; +import { createReportEmptyState } from "dofek-server/report-empty-state"; import { describe, expect, it } from "vitest"; import { MonthlyReportContent } from "./MonthlyReportContent.tsx"; -import { monthlyReportEmptyStateFixture } from "./report-empty-state-fixtures.ts"; describe("MonthlyReportContent", () => { it("renders current and previous monthly snapshots", () => { @@ -42,7 +42,7 @@ describe("MonthlyReportContent", () => { whatToTryNext: ["Repeat the routine next month."], confidenceAndMissingData: ["Confidence is limited."], }, - emptyState: monthlyReportEmptyStateFixture, + emptyState: createReportEmptyState("monthly"), }} />, ); diff --git a/packages/web/src/components/WeeklyReportCard.stories.tsx b/packages/web/src/components/WeeklyReportCard.stories.tsx index 3f63b1bbbd..fda019883c 100644 --- a/packages/web/src/components/WeeklyReportCard.stories.tsx +++ b/packages/web/src/components/WeeklyReportCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { weeklyReportEmptyStateFixture } from "./report-empty-state-fixtures.ts"; +import { createReportEmptyState } from "dofek-server/report-empty-state"; import { WeeklyReportCard } from "./WeeklyReportCard"; const meta = { @@ -51,7 +51,7 @@ const meta = { "These period averages can show co-movement, but they cannot establish cause and effect.", ], }, - emptyState: weeklyReportEmptyStateFixture, + emptyState: createReportEmptyState("weekly"), }, }, } satisfies Meta; @@ -75,24 +75,7 @@ export const Empty: Story = { current: null, history: [], decisionSupport: null, - emptyState: { - reportKind: "weekly", - title: "Your weekly report will appear here", - message: "No activity, sleep, or recovery data is available for this report yet.", - minimumObservedDays: 1, - acceptedDataTypes: ["activity", "sleep", "recovery"], - requirement: - "At least 1 observed day of activity, sleep, or recovery data is required to create a weekly report.", - previewTitle: "When ready, your weekly report will include", - previewItems: [ - "Training time and activity count", - "Average nightly sleep", - "Average resting heart rate", - "Average heart rate variability", - "Recent week comparisons", - ], - note: "This preview shows report sections only. No personal values or conclusions are estimated.", - }, + emptyState: createReportEmptyState("weekly"), }, }, }; diff --git a/packages/web/src/components/WeeklyReportCard.test.tsx b/packages/web/src/components/WeeklyReportCard.test.tsx index 4aac96dc40..e35f0ef181 100644 --- a/packages/web/src/components/WeeklyReportCard.test.tsx +++ b/packages/web/src/components/WeeklyReportCard.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ import { cleanup, render, screen } from "@testing-library/react"; +import { createReportEmptyState } from "dofek-server/report-empty-state"; import { afterEach, describe, expect, it } from "vitest"; -import { weeklyReportEmptyStateFixture } from "./report-empty-state-fixtures.ts"; import { WeeklyReportCard } from "./WeeklyReportCard.tsx"; afterEach(() => { @@ -62,7 +62,7 @@ describe("WeeklyReportCard", () => { whatToTryNext: ["Repeat the routine next week."], confidenceAndMissingData: ["Confidence is limited."], }, - emptyState: weeklyReportEmptyStateFixture, + emptyState: createReportEmptyState("weekly"), }} />, ); @@ -89,7 +89,7 @@ describe("WeeklyReportCard", () => { }, history: [], decisionSupport: null, - emptyState: weeklyReportEmptyStateFixture, + emptyState: createReportEmptyState("weekly"), }} />, ); @@ -114,7 +114,7 @@ describe("WeeklyReportCard", () => { }, history: [], decisionSupport: null, - emptyState: weeklyReportEmptyStateFixture, + emptyState: createReportEmptyState("weekly"), }} />, ); @@ -151,7 +151,7 @@ describe("WeeklyReportCard", () => { }, ], decisionSupport: null, - emptyState: weeklyReportEmptyStateFixture, + emptyState: createReportEmptyState("weekly"), }} />, ); diff --git a/packages/web/src/components/report-empty-state-fixtures.ts b/packages/web/src/components/report-empty-state-fixtures.ts deleted file mode 100644 index 967b86ddae..0000000000 --- a/packages/web/src/components/report-empty-state-fixtures.ts +++ /dev/null @@ -1,38 +0,0 @@ -export const weeklyReportEmptyStateFixture = { - reportKind: "weekly" as const, - title: "Your weekly report will appear here", - message: "No activity, sleep, or recovery data is available for this report yet.", - minimumObservedDays: 1 as const, - acceptedDataTypes: ["activity", "sleep", "recovery"] as const, - requirement: - "At least 1 observed day of activity, sleep, or recovery data is required to create a weekly report.", - previewTitle: "When ready, your weekly report will include", - previewItems: [ - "Training time and activity count", - "Average nightly sleep", - "Average resting heart rate", - "Average heart rate variability", - "Recent week comparisons", - ], - note: "This preview shows report sections only. No personal values or conclusions are estimated.", -}; - -export const monthlyReportEmptyStateFixture = { - reportKind: "monthly" as const, - title: "Your monthly report will appear here", - message: "No activity, sleep, or recovery data is available for this report yet.", - minimumObservedDays: 1 as const, - acceptedDataTypes: ["activity", "sleep", "recovery"] as const, - requirement: - "At least 1 observed day of activity, sleep, or recovery data is required to create a monthly report.", - previewTitle: "When ready, your monthly report will include", - previewItems: [ - "Training time and activity count", - "Average daily strain", - "Average sleep duration", - "Average resting heart rate", - "Average heart rate variability", - "Month-over-month training and sleep changes", - ], - note: "This preview shows report sections only. No personal values or conclusions are estimated.", -}; diff --git a/scripts/mobile-catch-telemetry-policy.test.ts b/scripts/mobile-catch-telemetry-policy.test.ts index 6ce47023bc..002aa68591 100644 --- a/scripts/mobile-catch-telemetry-policy.test.ts +++ b/scripts/mobile-catch-telemetry-policy.test.ts @@ -75,6 +75,20 @@ describe("findHandledMobileErrorViolations", () => { }); it.each([ + [ + "reports through a canonical workout-route helper", + ` + try { + await pushRoutes(); + } catch (error) { + handleWorkoutRouteError(error, { + errors, + errorLabel: "Push workout routes", + source: "health-kit-workout-route-push", + }); + } + `, + ], [ "reports the original error through the canonical helper", ` diff --git a/scripts/mobile-catch-telemetry-policy.ts b/scripts/mobile-catch-telemetry-policy.ts index df1e18e682..d888ff54bb 100644 --- a/scripts/mobile-catch-telemetry-policy.ts +++ b/scripts/mobile-catch-telemetry-policy.ts @@ -10,11 +10,13 @@ export interface HandledMobileErrorViolation { line: number; } +const CANONICAL_ERROR_REPORTERS = new Set(["captureException", "handleWorkoutRouteError"]); + function isCanonicalCaptureCall(node: ts.Node): boolean { return ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - node.expression.text === "captureException" + CANONICAL_ERROR_REPORTERS.has(node.expression.text) ); } @@ -84,7 +86,7 @@ function containsCanonicalCaptureOrThrow(node: ts.Node, caughtIdentifier?: strin if (isCanonicalCaptureCall(node)) { return true; } - if (ts.isIdentifier(node) && node.text === "captureException") { + if (ts.isIdentifier(node) && CANONICAL_ERROR_REPORTERS.has(node.text)) { return true; } if (ts.isBlock(node)) {