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)) {