Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fd448bd
feat: add processing event ledger
Asherlc Jul 22, 2026
b2d462b
feat: track metric stream acknowledgements
Asherlc Jul 22, 2026
b6630bb
feat: reconcile downstream processing
Asherlc Jul 22, 2026
3880216
feat: expose processing status API
Asherlc Jul 22, 2026
a36bd21
feat: show processing status on web
Asherlc Jul 22, 2026
1cb207d
feat: show processing status on mobile
Asherlc Jul 22, 2026
71e26a0
fix: address processing ledger review
Asherlc Jul 22, 2026
d1d6b64
Merge branch 'Asherlc/address-issue-1852' into Asherlc/issue-1852-met…
Asherlc Jul 22, 2026
6e48dd7
Merge branch 'Asherlc/issue-1852-metric-ack' into Asherlc/issue-1852-…
Asherlc Jul 22, 2026
22d4fbc
Merge branch 'Asherlc/issue-1852-reconciliation' into Asherlc/issue-1…
Asherlc Jul 22, 2026
07712c9
Merge branch 'Asherlc/issue-1852-api' into Asherlc/issue-1852-web
Asherlc Jul 22, 2026
eb7a825
Merge branch 'Asherlc/issue-1852-web' into Asherlc/issue-1852-mobile
Asherlc Jul 22, 2026
894adca
test: kill dataset contract mutants
Asherlc Jul 22, 2026
1ad47e5
Merge branch 'Asherlc/address-issue-1852' into Asherlc/issue-1852-met…
Asherlc Jul 22, 2026
3ba6c1d
Merge branch 'Asherlc/issue-1852-metric-ack' into Asherlc/issue-1852-…
Asherlc Jul 22, 2026
1b32940
Merge branch 'Asherlc/issue-1852-reconciliation' into Asherlc/issue-1…
Asherlc Jul 22, 2026
af4032d
Merge branch 'Asherlc/issue-1852-api' into Asherlc/issue-1852-web
Asherlc Jul 22, 2026
1c51cf1
Merge branch 'Asherlc/issue-1852-web' into Asherlc/issue-1852-mobile
Asherlc Jul 22, 2026
1764b3b
chore: merge origin/main
Asherlc Jul 23, 2026
4df0d8b
fix: correct processing status refresh
Asherlc Jul 23, 2026
93b74ca
fix: refresh processing status on pull
Asherlc Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions packages/mobile/app/(tabs)/activities.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ let mockDataHealthQuery: {
isLoading: boolean;
error: Error | null;
};
let dataHealthInput: unknown;
let processingStatusInput: unknown;
const routerPush = vi.fn();

vi.mock("expo-router", () => ({
Expand Down Expand Up @@ -84,10 +84,10 @@ vi.mock("../../lib/trpc", () => ({
}),
},
},
sync: {
dataHealth: {
processing: {
status: {
useQuery: (input: unknown) => {
dataHealthInput = input;
processingStatusInput = input;
return mockDataHealthQuery;
},
},
Expand All @@ -100,8 +100,8 @@ vi.mock("../../lib/trpc", () => ({
activity: {
list: { invalidate: invalidateActivityList },
},
sync: {
dataHealth: { invalidate: invalidateDataHealth },
processing: {
status: { invalidate: invalidateDataHealth },
},
}),
},
Expand Down Expand Up @@ -171,7 +171,7 @@ describe("ActivitiesScreen", () => {
invalidateActivityOverview = vi.fn();
invalidateActivityList = vi.fn();
mockDataHealthQuery = { data: undefined, isLoading: false, error: null };
dataHealthInput = undefined;
processingStatusInput = undefined;
invalidateDataHealth = vi.fn();
routerPush.mockReset();
vi.restoreAllMocks();
Expand All @@ -182,6 +182,8 @@ describe("ActivitiesScreen", () => {
data: {
overallStatus: "blocked",
generatedAt: "2026-06-30T08:00:00.000Z",
scope: { providerId: null, datasets: ["activity"] },
operations: [],
datasets: [
{
key: "activity",
Expand All @@ -202,11 +204,8 @@ describe("ActivitiesScreen", () => {

render(<ActivitiesScreen />);

expect(dataHealthInput).toEqual({ datasets: ["activity"] });
expect(screen.getByText("Some data is temporarily unavailable")).toBeDefined();
expect(
screen.getByText("Activities data is still being prepared. Please check back soon."),
).toBeDefined();
expect(processingStatusInput).toEqual({ datasets: ["activity"] });
expect(screen.getByText("Processing needs attention")).toBeDefined();
});

it("uses QueryStatePanel for overview loading state", () => {
Expand Down
15 changes: 8 additions & 7 deletions packages/mobile/app/(tabs)/activities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import {
} from "react-native";
import Svg, { Polyline } from "react-native-svg";
import { ActivityTypeIcon } from "../../components/ActivityTypeIcon";
import { DataReadinessBanner } from "../../components/DataReadinessBanner";
import { ProcessingStatusWidget } from "../../components/ProcessingStatusWidget";
import { QueryStatePanel } from "../../components/QueryStatePanel";
import { trpc } from "../../lib/trpc";
import { useUnitConverter } from "../../lib/units";
import { useProcessingStatus } from "../../lib/useProcessingStatus";
import { useRefresh } from "../../lib/useRefresh";
import { colors, radius, spacing } from "../../theme";

Expand Down Expand Up @@ -81,7 +82,7 @@ export default function ActivitiesScreen() {
const overviewQuery = trpc.calendar.activityOverview.useQuery(queryInput, {
placeholderData: (previousData) => previousData,
});
const dataHealth = trpc.sync.dataHealth.useQuery({ datasets: ["activity"] });
const processingStatus = useProcessingStatus({ datasets: ["activity"] });
const bulkDelete = trpc.activity.bulkDelete.useMutation({
onSuccess: async () => {
await trpcUtils.calendar.weekList.invalidate();
Expand All @@ -97,7 +98,7 @@ export default function ActivitiesScreen() {
trpcUtils.calendar.weekList.invalidate(),
trpcUtils.calendar.activityOverview.invalidate(),
trpcUtils.activity.list.invalidate(),
trpcUtils.sync.dataHealth.invalidate(),
trpcUtils.processing.status.invalidate(),
]).then(() => undefined),
});

Expand Down Expand Up @@ -165,10 +166,10 @@ export default function ActivitiesScreen() {
<Text style={styles.recordButtonText}>Record Activity</Text>
</TouchableOpacity>

<DataReadinessBanner
data={dataHealth.data}
error={dataHealth.error}
loading={dataHealth.isLoading}
<ProcessingStatusWidget
data={processingStatus.data}
error={processingStatus.error}
loading={processingStatus.isLoading}
/>

<ActivityControls
Expand Down
22 changes: 12 additions & 10 deletions packages/mobile/app/(tabs)/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ vi.mock("../../lib/trpc", () => ({
},
},
},
sync: {
dataHealth: {
processing: {
status: {
useQuery: (...parameters: unknown[]) => {
mockDataHealthUseQuery(...parameters);
return {
Expand Down Expand Up @@ -188,8 +188,10 @@ describe("TodayScreen independent loading states", () => {

it("shows data readiness when dashboard summaries are stale", async () => {
mockDataHealthData = {
overallStatus: "stale",
overallStatus: "delayed",
generatedAt: "2026-06-30T08:00:00.000Z",
scope: { providerId: null, datasets: ["activity", "sleep", "recovery", "training", "body"] },
operations: [],
datasets: [
{
key: "dailyMetrics",
Expand All @@ -208,13 +210,13 @@ describe("TodayScreen independent loading states", () => {
const { default: TodayScreen } = await import("./index");
render(<TodayScreen />);

expect(mockDataHealthUseQuery).toHaveBeenCalledWith({ datasets: ["dailyMetrics"] });
expect(screen.getByText("Dashboard summaries are catching up")).toBeTruthy();
expect(
screen.getByText(
"Daily metrics data is synced, but dashboard summaries are still catching up.",
),
).toBeTruthy();
expect(mockDataHealthUseQuery).toHaveBeenCalledWith(
{
datasets: ["activity", "sleep", "recovery", "training", "body"],
},
expect.any(Object),
);
expect(screen.getByText("Processing is taking longer than expected")).toBeTruthy();
});

afterEach(() => {
Expand Down
17 changes: 10 additions & 7 deletions packages/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import { ChartTitleWithTooltip } from "../../components/ChartTitleWithTooltip";
import { RecoveryRing } from "../../components/charts/RecoveryRing";
import { SleepBar } from "../../components/charts/SleepBar";
import { StrainGauge } from "../../components/charts/StrainGauge";
import { DataReadinessBanner } from "../../components/DataReadinessBanner";
import { ProcessingStatusWidget } from "../../components/ProcessingStatusWidget";
import { ProviderGuide } from "../../components/ProviderGuide";
import { getQueryErrorMessage, QueryStatePanel } from "../../components/QueryStatePanel";
import { SkeletonCircle } from "../../components/Skeleton";
import { trpc } from "../../lib/trpc";
import { useAutoSync } from "../../lib/useAutoSync";
import { useProcessingStatus } from "../../lib/useProcessingStatus";
import { useProviderGuide } from "../../lib/useProviderGuide";
import { useRefresh } from "../../lib/useRefresh";
import { useTodayQueryDate } from "../../lib/useTodayQueryDate";
Expand All @@ -47,7 +48,9 @@ export default function TodayScreen() {
{ endDate },
{ placeholderData: (previousData) => previousData },
);
const dataHealthQuery = trpc.sync.dataHealth.useQuery({ datasets: ["dailyMetrics"] });
const processingStatusQuery = useProcessingStatus({
datasets: ["activity", "sleep", "recovery", "training", "body"],
});
const dashboardData = dashboardQuery.data;
const anomalyQuery = trpc.anomalyDetection.check.useQuery(
{ endDate },
Expand Down Expand Up @@ -87,7 +90,7 @@ export default function TodayScreen() {
await Promise.all([
dashboardQuery.refetch(),
anomalyQuery.refetch(),
dataHealthQuery.refetch(),
processingStatusQuery.refetch(),
]);
},
invalidate: null,
Expand Down Expand Up @@ -125,10 +128,10 @@ export default function TodayScreen() {
<ProviderGuide onDismiss={providerGuide.dismiss} providers={providerGuide.providers} />
)}

<DataReadinessBanner
data={dataHealthQuery.data}
error={dataHealthQuery.error}
loading={dataHealthQuery.isLoading}
<ProcessingStatusWidget
data={processingStatusQuery.data}
error={processingStatusQuery.error}
loading={processingStatusQuery.isLoading}
/>

{hasBackgroundError ? (
Expand Down
33 changes: 29 additions & 4 deletions packages/mobile/app/(tabs)/recovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ let mockRecoveryData: Record<string, unknown> | undefined;
let mockRecoveryLoading = false;
let mockRecoveryFetching = false;
let sparkLinePropsCalls: Record<string, unknown>[];
const mockRecoveryInvalidate = vi.fn();
const mockProcessingStatusInvalidate = vi.fn();
let mockRefreshInvalidate: (() => Promise<void> | void) | null | undefined;

vi.mock("../../lib/trpc", () => ({
trpc: {
Expand All @@ -19,14 +22,17 @@ vi.mock("../../lib/trpc", () => ({
}),
},
},
sync: {
dataHealth: {
processing: {
status: {
useQuery: () => ({ data: undefined, isLoading: false, error: null }),
},
},
useUtils: () => ({
mobileDashboard: {
recovery: { invalidate: vi.fn() },
recovery: { invalidate: mockRecoveryInvalidate },
},
processing: {
status: { invalidate: mockProcessingStatusInvalidate },
},
}),
},
Expand All @@ -53,7 +59,10 @@ vi.mock("../../lib/units", async () => {
});

vi.mock("../../lib/useRefresh", () => ({
useRefresh: () => ({ refreshing: false, onRefresh: vi.fn() }),
useRefresh: (input: { invalidate?: (() => Promise<void> | void) | null }) => {
mockRefreshInvalidate = input.invalidate;
return { refreshing: false, onRefresh: vi.fn() };
},
}));

vi.mock("../../theme", () => ({
Expand Down Expand Up @@ -85,6 +94,22 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => {
mockRecoveryLoading = false;
mockRecoveryFetching = false;
sparkLinePropsCalls = [];
mockRecoveryInvalidate.mockReset();
mockProcessingStatusInvalidate.mockReset();
mockRecoveryInvalidate.mockResolvedValue(undefined);
mockProcessingStatusInvalidate.mockResolvedValue(undefined);
mockRefreshInvalidate = undefined;
});

it("refreshes recovery data and processing status together", async () => {
const { default: RecoveryScreen } = await import("./recovery");
render(<RecoveryScreen />);

expect(mockRefreshInvalidate).toBeTypeOf("function");
await mockRefreshInvalidate?.();

expect(mockRecoveryInvalidate).toHaveBeenCalledOnce();
expect(mockProcessingStatusInvalidate).toHaveBeenCalledOnce();
});

it("keeps day selector visible while recovery data is loading", async () => {
Expand Down
19 changes: 12 additions & 7 deletions packages/mobile/app/(tabs)/recovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ import {
} from "react-native";
import { Card } from "../../components/Card";
import { SparkLine } from "../../components/charts/SparkLine";
import { DataReadinessBanner } from "../../components/DataReadinessBanner";
import { DaySelector } from "../../components/DaySelector";
import { MetricCard } from "../../components/MetricCard";
import { ProcessingStatusWidget } from "../../components/ProcessingStatusWidget";
import { QueryStatePanel } from "../../components/QueryStatePanel";
import { trpc } from "../../lib/trpc";
import { useUnitConverter } from "../../lib/units";
import { useProcessingStatus } from "../../lib/useProcessingStatus";
import { useRefresh } from "../../lib/useRefresh";
import { useTodayQueryDate } from "../../lib/useTodayQueryDate";
import { colors } from "../../theme";
Expand Down Expand Up @@ -180,7 +181,7 @@ export default function RecoveryScreen() {
{ days, endDate },
{ placeholderData: (previousData) => previousData },
);
const dataHealth = trpc.sync.dataHealth.useQuery({ datasets: ["dailyMetrics"] });
const processingStatus = useProcessingStatus({ datasets: ["activity", "sleep", "recovery"] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const recoveryData = recoveryQuery.data;

const hrvData = recoveryData?.hrvVariability ?? [];
Expand Down Expand Up @@ -239,7 +240,11 @@ export default function RecoveryScreen() {
isFetching: recoveryQuery.isFetching,
});
const { refreshing, onRefresh } = useRefresh({
invalidate: () => utils.mobileDashboard.recovery.invalidate().then(() => undefined),
invalidate: () =>
Promise.all([
utils.mobileDashboard.recovery.invalidate(),
utils.processing.status.invalidate(),
]).then(() => undefined),
});

return (
Expand All @@ -256,10 +261,10 @@ export default function RecoveryScreen() {
>
<DaySelector days={days} onChange={setDays} />

<DataReadinessBanner
data={dataHealth.data}
error={dataHealth.error}
loading={dataHealth.isLoading}
<ProcessingStatusWidget
data={processingStatus.data}
error={processingStatus.error}
loading={processingStatus.isLoading}
/>

{isLoading ? (
Expand Down
33 changes: 29 additions & 4 deletions packages/mobile/app/(tabs)/strain.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockRouterPush = vi.fn();
const mockTrainingInvalidate = vi.fn();
const mockProcessingStatusInvalidate = vi.fn();
let mockRefreshInvalidate: (() => Promise<void> | void) | null | undefined;

type MockTrainingData = Record<string, unknown>;
type MockTrainingState = {
Expand Down Expand Up @@ -74,21 +77,27 @@ vi.mock("../../lib/trpc", () => ({
}),
},
},
sync: {
dataHealth: {
processing: {
status: {
useQuery: () => ({ data: undefined, isLoading: false, error: null }),
},
},
useUtils: () => ({
mobileDashboard: {
training: { invalidate: vi.fn() },
training: { invalidate: mockTrainingInvalidate },
},
processing: {
status: { invalidate: mockProcessingStatusInvalidate },
},
}),
},
}));

vi.mock("../../lib/useRefresh", () => ({
useRefresh: () => ({ refreshing: false, onRefresh: vi.fn() }),
useRefresh: (input: { invalidate?: (() => Promise<void> | void) | null }) => {
mockRefreshInvalidate = input.invalidate;
return { refreshing: false, onRefresh: vi.fn() };
},
}));

vi.mock("../../lib/useTodayQueryDate", () => ({
Expand All @@ -114,9 +123,25 @@ describe("StrainScreen recent activity navigation", () => {
beforeEach(() => {
mockRouterPush.mockReset();
vi.mocked(captureException).mockReset();
mockTrainingInvalidate.mockReset();
mockProcessingStatusInvalidate.mockReset();
mockTrainingInvalidate.mockResolvedValue(undefined);
mockProcessingStatusInvalidate.mockResolvedValue(undefined);
mockRefreshInvalidate = undefined;
resetMockTrainingState();
});

it("refreshes training data and processing status together", async () => {
const { default: StrainScreen } = await import("./strain");
render(<StrainScreen />);

expect(mockRefreshInvalidate).toBeTypeOf("function");
await mockRefreshInvalidate?.();

expect(mockTrainingInvalidate).toHaveBeenCalledOnce();
expect(mockProcessingStatusInvalidate).toHaveBeenCalledOnce();
});

it("keeps day selector visible while training data is loading", async () => {
mockTrainingState.isLoading = true;
mockTrainingState.data = undefined;
Expand Down
Loading
Loading