Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
cf6af33
chore: ignore local worktrees
Asherlc Aug 7, 2026
b5e9cd6
Prevent mobile app test routes
Asherlc Aug 7, 2026
d9f06b4
Merge origin/main into mobile route hygiene
Asherlc Aug 7, 2026
9ebd8c7
fix: tighten mobile route hygiene guard
Asherlc Aug 7, 2026
c4ba57d
fix: handle no-patch audit advisories
Asherlc Aug 7, 2026
26a5159
fix: colocate mobile route fixture tests
Asherlc Aug 7, 2026
1c4e987
docs: specify processing alert clarity
Asherlc Aug 8, 2026
733a1bc
docs: specify partial activity totals
Asherlc Aug 8, 2026
ace3951
docs: plan partial activity measurements
Asherlc Aug 8, 2026
74a239d
docs: plan processing alert clarity
Asherlc Aug 8, 2026
7f0c22e
docs: clarify dismissal ownership tests
Asherlc Aug 8, 2026
ee746b4
feat: add processing alert dismissals
Asherlc Aug 8, 2026
c9c33d0
fix: name processing alert dismissal fks
Asherlc Aug 8, 2026
73395bf
feat: derive processing failure timestamps
Asherlc Aug 8, 2026
34be29b
fix: scope processing alerts to current ops
Asherlc Aug 8, 2026
8e3b6c4
feat: expose processing alert dismissal
Asherlc Aug 8, 2026
ab7db82
docs: align grouped alert contract
Asherlc Aug 9, 2026
0d72809
fix: align processing alert contract
Asherlc Aug 9, 2026
bb8cb15
fix: remove legacy processing alert key
Asherlc Aug 9, 2026
0bc6447
feat: group processing failure presentation
Asherlc Aug 9, 2026
b728c8d
feat: clarify web processing failures
Asherlc Aug 9, 2026
2322bd6
fix: preserve active processing state
Asherlc Aug 9, 2026
aefb7c1
feat: clarify mobile processing failures
Asherlc Aug 9, 2026
59f1b35
fix: color active recompute status
Asherlc Aug 9, 2026
4902724
test: cover processing alert persistence
Asherlc Aug 9, 2026
243e042
fix: align processing dismissal behavior
Asherlc Aug 9, 2026
8e081be
Add Paseo workspace scripts and lifecycle config (#2469)
Asherlc Aug 10, 2026
101c4a8
Add body fat percentage trend charts to web and mobile (#2470)
Asherlc Aug 10, 2026
bd2aece
fix(peloton): accept omitted total work (#2472)
Asherlc Aug 10, 2026
9f31ddd
Fix production Sentry failures
Asherlc Aug 10, 2026
94816d0
feat: add Hangboarding import, contracts, and UI (#2471)
Asherlc Aug 10, 2026
7b29556
Merge remote-tracking branch 'origin/codex/mobile-route-hygiene' into…
Asherlc Aug 10, 2026
ca1893c
Remove duplicate processing migration
Asherlc Aug 11, 2026
c8b6b81
Merge remote-tracking branch 'origin/main' into codex/mobile-route-hy…
Asherlc Aug 11, 2026
8bf4e94
Fix CI regressions from merge resolution
Asherlc Aug 11, 2026
358c396
Fix mutation coverage and merge main conflicts
Asherlc Aug 11, 2026
4fc70ad
Merge origin/main into mobile route hygiene
Asherlc Aug 11, 2026
bd71f4f
fix(sync): name failed provider
Asherlc Aug 12, 2026
3272151
fix(sync): tailor recovery guidance
Asherlc Aug 12, 2026
9704b4c
fix: address review feedback
Asherlc Aug 12, 2026
6d958af
refactor(mobile): split climbing detail section
Asherlc Aug 12, 2026
ad815bf
merge: resolve main conflicts
Asherlc Aug 12, 2026
a874f77
fix(ci): remove unused mobile component
Asherlc Aug 12, 2026
db92bce
docs: record PR CI incident
Asherlc Aug 12, 2026
ce3bac9
fix(ci): remove duplicate test type import
Asherlc Aug 12, 2026
4a06f45
docs: update CI incident outcome
Asherlc Aug 12, 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
9 changes: 9 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -23502,6 +23502,15 @@ Drizzle schema and runtime Zod schemas. Findings and remediations:
- **Remaining risk / follow-up:** Confirm the fresh Metro bundle and the
remaining hosted checks pass, then remove no configuration guards.

## 2026-08-12 — PR 2507 CI retained an orphaned mobile component after conflict resolution

- **Status:** Resolved by commits `a874f77` and `ce3bac9`; the repaired [Knip job](https://github.com/Asherlc/dofek/actions/runs/31632262084/job/94234312820), [Native FIT Decoder job](https://github.com/Asherlc/dofek/actions/runs/31632262084/job/94234312585), Mobile Preview OTA check, and [TFLint rerun](https://github.com/Asherlc/dofek/actions/runs/31633329057/job/94237945547) passed on fresh PR runs.
- **Symptoms / impact:** The post-merge [Knip job](https://github.com/Asherlc/dofek/actions/runs/31631878405/job/94232700644) and a subsequent [Lint job](https://github.com/Asherlc/dofek/actions/runs/31632651475/job/94235518353) blocked PR #2507. The same run also recorded independent artifact-download failures in the Native FIT Decoder, Mobile Preview OTA, and TFLint jobs. No production impact occurred.
- **Evidence / root cause:** The first fatal Knip finding was `Unused files (1): packages/mobile/components/ClimbingEntryBreakdown.tsx`; the merge retained an extracted component while `main` had already restored the inline consumer. The later Lint failure reported `noUnusedImports` for the provider-package `HangboardingSummary` import and `noRedeclare` for the repository type of the same name in `mobile-training-tab.test.ts`; both imports survived the merge although the test uses only the repository type. TFLint failed before reading repository configuration because its setup download ended with `socket hang up`.
- **Fix / mitigation:** Removed the orphaned component and the unused provider-package import. The artifact-download failures were upstream availability failures, so no retry, timeout, or failure-suppression change was added.
- **Validation:** Knip passed locally with CI's required non-secret Sentry configuration, the touched test's Biome check and 19 tests passed locally, and fresh hosted Knip, Native FIT Decoder, Mobile Preview OTA, and TFLint checks passed. The fresh full Lint job is still running.
- **Remaining risk / follow-up:** Confirm the fresh hosted Lint job and remaining CI matrix pass.

## 2026-08-11 — Wahoo sync required reconnect despite a usable refresh token

- **Status:** Fix prepared in the workspace; deployment and production
Expand Down
47 changes: 30 additions & 17 deletions packages/mobile/components/ProcessingStatusWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ 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, 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(),
};
});
const {
mockDismissOperation,
mockDismissState,
mockInvalidateAlerts,
mockInvalidateStatus,
mockPush,
} = vi.hoisted(() => {
const mockDismissState: { error: Error | null; isPending: boolean } = {
error: null,
isPending: false,
};
return {
mockDismissOperation: vi.fn(),
mockDismissState,
mockInvalidateAlerts: vi.fn(),
mockInvalidateStatus: vi.fn(),
mockPush: vi.fn(),
};
});

vi.mock("../lib/trpc", () => ({
trpc: {
Expand Down Expand Up @@ -45,6 +51,10 @@ vi.mock("../lib/trpc", () => ({
},
}));

vi.mock("expo-router", () => ({
useRouter: () => ({ push: mockPush }),
}));

const snapshot: ProcessingStatusSnapshot = {
generatedAt: "2026-07-22T12:00:00.000Z",
scope: { providerId: "garmin", datasets: ["activity"] },
Expand Down Expand Up @@ -127,7 +137,8 @@ function failedWahooSnapshot(overrides: Partial<ProcessingStatusSnapshot> = {})
status: "failed" as const,
datasets: failedDatasets.map((dataset) => dataset.key),
dismissed: false,
errorMessage: "Wahoo returned a server error. Reconnect Wahoo, then try again.",
errorCode: "provider_sync_failed",
errorMessage: "Wahoo could not be synced. Try the sync again later.",
timeline: [],
},
],
Expand Down Expand Up @@ -248,6 +259,7 @@ describe("ProcessingStatusWidget", () => {
status,
dismissed: false,
errorMessage: "Reconnect Garmin, then start the sync again.",
errorCode: "provider_auth_failed",
timeline: [
{
...timelineEvent,
Expand All @@ -266,6 +278,8 @@ describe("ProcessingStatusWidget", () => {
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();
fireEvent.click(screen.getByRole("button", { name: "Reconnect Garmin" }));
expect(mockPush).toHaveBeenCalledWith("/providers/garmin");
});

it("groups current failed datasets by operation and offers dismissal", () => {
Expand All @@ -283,9 +297,8 @@ describe("ProcessingStatusWidget", () => {
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.getByText("Wahoo could not be synced. Try the sync again later.")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Reconnect Wahoo" })).toBeNull();
expect(
screen.queryByText("Try the update again. If it still fails, reconnect the data source."),
).toBeNull();
Expand Down
49 changes: 39 additions & 10 deletions packages/mobile/components/ProcessingStatusWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
processingStatusMessage,
processingTarget,
} from "@dofek/providers/processing-status";
import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { trpc } from "../lib/trpc";
import { colors, spacing } from "../theme";
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface ProcessingStatusSnapshot {
datasets: string[];
dismissed: boolean;
errorMessage: string | null;
errorCode?: string | null;
timeline: Array<{
sequence: number;
stage: ProcessingDisplayStage;
Expand Down Expand Up @@ -68,6 +70,7 @@ export function ProcessingStatusWidget({
contextLabel,
alwaysVisible = false,
}: ProcessingStatusWidgetProps) {
const router = useRouter();
const trpcUtils = trpc.useUtils();
const dismissMutation = trpc.processing.dismiss.useMutation({
onSuccess: async () => {
Expand Down Expand Up @@ -183,16 +186,33 @@ export function ProcessingStatusWidget({
<Text style={styles.datasetError}>{group.errorMessage}</Text>
) : null}
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Dismiss ${labelPrefix} failure`}
accessibilityState={{ disabled: dismissMutation.isPending }}
disabled={dismissMutation.isPending}
onPress={() => dismissMutation.mutate({ operationId: group.operationId })}
style={[styles.dismissButton, dismissMutation.isPending && styles.actionDisabled]}
>
<Text style={styles.dismissButtonText}>Dismiss</Text>
</Pressable>
<View style={styles.failureActions}>
{group.requiresReconnect && group.providerId && group.providerLabel ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Reconnect ${group.providerLabel}`}
onPress={() => router.push(`/providers/${group.providerId}`)}
style={styles.reconnectButton}
>
<Text style={styles.reconnectButtonText}>
Reconnect {group.providerLabel}
</Text>
</Pressable>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Dismiss ${labelPrefix} failure`}
accessibilityState={{ disabled: dismissMutation.isPending }}
disabled={dismissMutation.isPending}
onPress={() => dismissMutation.mutate({ operationId: group.operationId })}
style={[
styles.dismissButton,
dismissMutation.isPending && styles.actionDisabled,
]}
>
<Text style={styles.dismissButtonText}>Dismiss</Text>
</Pressable>
</View>
</View>
</View>
);
Expand Down Expand Up @@ -253,6 +273,15 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
},
failureGroupCopy: { flex: 1, gap: 2 },
failureActions: { gap: spacing.xs },
reconnectButton: {
alignItems: "center",
backgroundColor: colors.accent,
borderRadius: 6,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
reconnectButtonText: { color: "#ffffff", fontSize: 12, fontWeight: "700" },
dismissButton: {
alignItems: "center",
borderColor: colors.surfaceSecondary,
Expand Down
3 changes: 2 additions & 1 deletion packages/providers-meta/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"./processing-alerts": "./src/processing-alerts.ts",
"./processing-status": "./src/processing-status.ts",
"./sync-actions": "./src/sync-actions.ts",
"./auth-provider-grouping": "./src/auth-provider-grouping.ts"
"./auth-provider-grouping": "./src/auth-provider-grouping.ts",
"./hangboarding": "./src/hangboarding.ts"
},
"dependencies": {
"@dofek/format": "workspace:*"
Expand Down
43 changes: 43 additions & 0 deletions packages/providers-meta/src/hangboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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;
}>;
}
35 changes: 35 additions & 0 deletions packages/providers-meta/src/processing-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,24 +202,59 @@ describe("processing status presentation", () => {
datasets: ["sleep", "activity", "recovery"],
dismissed: false,
errorMessage: "Reconnect WHOOP.",
errorCode: "provider_auth_failed",
},
],
});

expect(groups).toEqual([
{
operationId: firstOperationId,
providerId: "whoop",
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.",
requiresReconnect: true,
Comment thread
Asherlc marked this conversation as resolved.
dismissed: false,
},
]);
});

it("does not require reconnecting for a retry-only provider failure", () => {
const groups = processingFailureGroups({
datasets: [
{
key: "activity",
label: "Activities",
status: "failed",
lastFailedAt: "2026-07-22T14:00:00.000Z",
lastReadyAt: null,
},
],
operations: [
{
id: firstOperationId,
providerId: "zwift",
status: "failed",
datasets: ["activity"],
dismissed: false,
errorMessage: "Zwift could not be synced. Try the sync again later.",
errorCode: "provider_sync_failed",
},
],
});

expect(groups).toEqual([
expect.objectContaining({
providerId: "zwift",
requiresReconnect: false,
}),
]);
});

it("does not expose dismissed or later-ready operation groups", () => {
expect(
processingFailureGroups({
Expand Down
5 changes: 5 additions & 0 deletions packages/providers-meta/src/processing-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,19 @@ interface ProcessingFailureOperation {
datasets: readonly string[];
dismissed: boolean;
errorMessage: string | null;
errorCode?: string | null;
}

export interface ProcessingFailureGroup {
operationId: string;
providerId: string | null;
providerLabel: string | null;
datasetLabels: string[];
status: "blocked" | "failed";
failedAt: string | null;
lastReadyAt: string | null;
errorMessage: string | null;
requiresReconnect: boolean;
dismissed: boolean;
}

Expand Down Expand Up @@ -227,12 +230,14 @@ export function processingFailureGroups(input: {
return [
{
operationId: operation.id,
providerId: operation.providerId,
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,
requiresReconnect: operation.errorCode === "provider_auth_failed",
dismissed: operation.dismissed,
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ describe("CyclingAnalyticsRepository", () => {
]);

const repository = new CyclingAnalyticsRepository(
{ execute: vi.fn().mockResolvedValue([]) },
{
execute: vi.fn().mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111" }]),
},
"11111111-1111-4111-8111-111111111111",
"UTC",
sensorStore,
Expand Down Expand Up @@ -195,7 +197,9 @@ describe("CyclingAnalyticsRepository", () => {
])
.mockResolvedValueOnce([performanceActivityRow()]);
const repository = new CyclingAnalyticsRepository(
{ execute: vi.fn().mockResolvedValue([]) },
{
execute: vi.fn().mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111" }]),
},
"11111111-1111-4111-8111-111111111111",
"UTC",
sensorStore,
Expand Down
Loading