Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@ jobs:
| { grep -v 'src/db/drizzle-schema\.ts$' || true; } \
| { grep -v 'src/db/schema/' || true; } \
| { grep -v 'packages/server/src/types\.ts$' || true; } \
| { grep -v '^packages/training/src/terminology\.ts$' || true; } \
| { grep -v 'scripts/' || true; } \
| { grep -v '^cypress/' || true; } \
| { grep -v 'packages/mobile/' || true; } \
Expand Down
47 changes: 47 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ full incident log or a replacement for runbooks. Use it to build shared memory
about the kinds of issues this system encounters, the signals that identified
them, and the durability work they suggest.

## 2026-08-02: Mobile Metro CI validation blocked by Infisical network timeout

### Symptoms

The `Build Mobile / Metro Bundle` check for PR #2400 failed before the Metro
bundle command ran.

### User Impact

There was no production or end-user impact. The pull request's mobile bundle
validation was blocked, while local focused tests and typechecks remained
available.

### Evidence

The first fatal line in [job 91481471693](https://github.com/Asherlc/dofek/actions/runs/30742155277/job/91481471693)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
was `dial tcp 44.207.179.12:443: i/o timeout` while the shared
`load-infisical-secrets` action authenticated with Infisical's OIDC endpoint.
The failure occurred before Metro bundling.

### Root Cause

The GitHub-hosted runner could not reach `app.infisical.com` during OIDC
authentication, so required mobile build secrets were never loaded ([job
91481471693](https://github.com/Asherlc/dofek/actions/runs/30742155277/job/91481471693)).

### Fix or Mitigation

No repository workaround, retry, timeout, or degraded-secret behavior was
added. The code change was validated locally with focused web/mobile tests,
typechecks, and static checks; CI should be rerun after Infisical connectivity
recovers.

### Remaining Risk

The mobile Metro bundle and any dependent CI gates remain unverified until the
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Infisical OIDC request succeeds on a subsequent workflow run.

### Follow-Up Work

Rerun CI after Infisical connectivity recovers and retain the successful
`Build Mobile / Metro Bundle` job and its dependent test-gate results as the
validation evidence for this incident. The follow-up run is
[CI run 30742835268](https://github.com/Asherlc/dofek/actions/runs/30742835268);
no runtime retry or timeout change is warranted unless that run reproduces the
connectivity failure.

## 2026-08-02: iOS cold start blocked by Expo OTA launch wait

### Symptoms
Expand Down
3 changes: 3 additions & 0 deletions packages/format/src/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { FormattedMeasurement, FormattedMeasurementPart, NullableNumber } f

export type UnitSystem = "metric" | "imperial";

export const POWER_UNIT_LABEL = "W";
export const WORK_UNIT_LABEL = "kJ";

// --- Conversion constants ---
const KG_TO_LBS = 2.20462;
const KM_TO_MILES = 0.621371;
Expand Down
48 changes: 39 additions & 9 deletions packages/mobile/app/(tabs)/strain.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { fireEvent, render, screen } from "@testing-library/react";
import { Alert } from "react-native";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockRouterPush = vi.fn();
Expand Down Expand Up @@ -303,7 +304,7 @@ describe("StrainScreen recent activity navigation", () => {
expect(mockProcessingStatusInvalidate).toHaveBeenCalledOnce();
});

it("renders server-owned intensity and polarization models", async () => {
it("leads with plain labels while keeping server-owned model details accessible", async () => {
mockHrZonesState.data = {
maxHr: 190,
weeks: [],
Expand Down Expand Up @@ -368,14 +369,33 @@ describe("StrainScreen recent activity navigation", () => {
];

const { default: StrainScreen } = await import("./strain");
const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});
render(<StrainScreen />);

expect(screen.getByText("Karvonen Intensity Distribution")).toBeTruthy();
expect(screen.getByText("Mobile descriptive intensity explanation.")).toBeTruthy();
expect(screen.getByText("Heart-rate zone distribution")).toBeTruthy();
expect(
screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
).toBeTruthy();
expect(screen.queryByText("Mobile descriptive intensity explanation.")).toBeNull();
expect(screen.getByText("Not polarized")).toBeTruthy();
expect(screen.getByText("Server says exactly 2.00 is not polarized.")).toBeTruthy();
expect(screen.getByText("Training Monotony & Strain")).toBeTruthy();
expect(screen.getByText("Mobile Foster formula.")).toBeTruthy();
expect(
screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
).toBeTruthy();
expect(screen.queryByText("Server says exactly 2.00 is not polarized.")).toBeNull();
expect(screen.getByText("Training variety and total load")).toBeTruthy();
expect(screen.queryByText("Mobile Foster formula.")).toBeNull();

fireEvent.click(
screen.getByRole("button", {
name: "About How this is calculated for Easy-to-hard training balance",
}),
);
expect(alertSpy).toHaveBeenCalledWith(
"How this is calculated for Easy-to-hard training balance",
expect.stringContaining("Server says exactly 2.00 is not polarized."),
[{ text: "Close" }],
);
alertSpy.mockRestore();
});

it("renders intensity, polarization, and monotony query failures separately", async () => {
Expand All @@ -392,6 +412,8 @@ describe("StrainScreen recent activity navigation", () => {
expect(screen.getByText("Intensity distribution failed")).toBeTruthy();
expect(screen.getByText("Cycling polarization failed")).toBeTruthy();
expect(screen.getByText("Training monotony failed")).toBeTruthy();
expect(screen.getByText("Could not load easy-to-hard training balance")).toBeTruthy();
expect(screen.getByText("Could not load training variety and total load")).toBeTruthy();
expect(captureException).toHaveBeenCalledWith(mockHrZonesState.error);
expect(captureException).toHaveBeenCalledWith(mockPolarizationState.error);
expect(captureException).toHaveBeenCalledWith(mockMonotonyState.error);
Expand Down Expand Up @@ -437,8 +459,10 @@ describe("StrainScreen recent activity navigation", () => {

expect(screen.getByText("Intensity refresh failed")).toBeTruthy();
expect(screen.getByText("Polarization refresh failed")).toBeTruthy();
expect(screen.getByText("Cached mobile intensity distribution.")).toBeTruthy();
expect(screen.getByText("No cycling polarization data in this period")).toBeTruthy();
expect(
screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
).toBeTruthy();
expect(screen.getByText("No easy-to-hard training balance data in this period")).toBeTruthy();
});

it("keeps day selector visible while training data is loading", async () => {
Expand Down Expand Up @@ -922,7 +946,13 @@ describe("StrainScreen recent activity navigation", () => {
render(<StrainScreen />);

expect(screen.getAllByText("Training data failed to load")).toHaveLength(1);
expect(screen.getByText("Independent intensity data remains available.")).toBeTruthy();
expect(
screen
.getByRole("button", {
name: "About How this is calculated for Heart-rate zone distribution",
})
.getAttribute("aria-description"),
).toContain("Independent intensity data remains available.");
});

it("keeps equal messages separate when training and companion queries both fail", async () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/mobile/app/(tabs)/strain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@dofek/format/format";
import { shouldShowBlockingLoading } from "@dofek/scoring/loading-policy";
import { aggregateWeeklyVolume, StrainScore } from "@dofek/scoring/scoring";
import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import {
collapseWeeklyVolumeActivityTypes,
formatActivityTypeLabel,
Expand Down Expand Up @@ -403,7 +404,15 @@ export default function StrainScreen() {

{/* Workload breakdown */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Training Load</Text>
<ChartTitleWithTooltip
title="Training Load"
description={
workloadResult == null
? "Daily training load and recent-versus-baseline comparison."
: `Technical name: ${TRAINING_TERMINOLOGY.workloadRatio.technicalName}. ${TRAINING_TERMINOLOGY.workloadRatio.details}`
}
textStyle={styles.cardTitle}
/>
<View style={styles.loadGrid}>
<View style={styles.loadItem}>
<Text style={styles.loadValue}>{formatTrainingLoad(acuteLoad)}</Text>
Expand Down Expand Up @@ -601,7 +610,7 @@ export default function StrainScreen() {
{polarizationQuery.isError ? (
<QueryStatePanel
variant="error"
title="Could not load cycling polarization"
title={`Could not load ${TRAINING_TERMINOLOGY.polarization.plainLabel.toLowerCase()}`}
message={polarizationQuery.error.message}
/>
) : polarizationQuery.isLoading && polarizationQuery.data == null ? (
Expand All @@ -611,7 +620,7 @@ export default function StrainScreen() {
{monotonyQuery.isError ? (
<QueryStatePanel
variant="error"
title="Could not load training monotony"
title={`Could not load ${TRAINING_TERMINOLOGY.monotony.plainLabel.toLowerCase()}`}
message={monotonyQuery.error.message}
/>
) : monotonyQuery.isLoading && monotonyQuery.data == null ? (
Expand Down
83 changes: 70 additions & 13 deletions packages/mobile/components/TrainingDistributionCards.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { fireEvent, render, screen } from "@testing-library/react";
import { Alert } from "react-native";
import { describe, expect, it, vi } from "vitest";

vi.mock("../lib/open-external-url", () => ({
Expand All @@ -11,7 +12,9 @@ import { openExternalUrl } from "../lib/open-external-url";
import { TrainingDistributionCards } from "./TrainingDistributionCards";

describe("TrainingDistributionCards", () => {
it("renders server-computed Karvonen percentages without classifying them", () => {
it("leads with a plain description and keeps the server explanation accessible", () => {
const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});

render(
<TrainingDistributionCards
intensityDistribution={{
Expand All @@ -29,15 +32,31 @@ describe("TrainingDistributionCards", () => {
/>,
);

expect(screen.getByText("Karvonen Intensity Distribution")).toBeTruthy();
expect(screen.getByText("Heart-rate zone distribution")).toBeTruthy();
expect(screen.getByText("Recovery")).toBeTruthy();
expect(screen.getByText("25%")).toBeTruthy();
expect(screen.getByText("Aerobic")).toBeTruthy();
expect(screen.getByText("75%")).toBeTruthy();
expect(screen.getByText("Server-provided descriptive Karvonen explanation.")).toBeTruthy();
expect(
screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
).toBeTruthy();
expect(screen.queryByText("Server-provided descriptive Karvonen explanation.")).toBeNull();
fireEvent.click(
screen.getByRole("button", {
name: "About How this is calculated for Heart-rate zone distribution",
}),
);
expect(alertSpy).toHaveBeenCalledWith(
"How this is calculated for Heart-rate zone distribution",
expect.stringContaining("Server-provided descriptive Karvonen explanation."),
[{ text: "Close" }],
);
alertSpy.mockRestore();
});

it("renders the exact Treff status and explanation returned by the server", () => {
const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});

render(
<TrainingDistributionCards
intensityDistribution={null}
Expand Down Expand Up @@ -76,13 +95,36 @@ describe("TrainingDistributionCards", () => {
/>,
);

expect(screen.getByText("Cycling Polarization")).toBeTruthy();
expect(screen.getByText("Easy-to-hard training balance")).toBeTruthy();
expect(screen.getByText("Not polarized")).toBeTruthy();
expect(screen.getByText("The exact 2.00 boundary is not polarized.")).toBeTruthy();
expect(
screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
).toBeTruthy();
expect(screen.getByText("80% easy · 10% threshold · 10% high")).toBeTruthy();
expect(screen.getByText("Easy-to-hard balance 2.000")).toBeTruthy();
expect(screen.queryByText("The exact 2.00 boundary is not polarized.")).toBeNull();
expect(screen.queryByText("Server-provided Treff formula.")).toBeNull();
fireEvent.click(
screen.getByRole("button", {
name: "About How this is calculated for Easy-to-hard training balance",
}),
);
expect(alertSpy).toHaveBeenCalledWith(
"How this is calculated for Easy-to-hard training balance",
expect.stringContaining("Technical name: Polarization Index"),
[{ text: "Close" }],
);
expect(alertSpy).toHaveBeenCalledWith(
"How this is calculated for Easy-to-hard training balance",
expect.stringContaining("The exact 2.00 boundary is not polarized."),
[{ text: "Close" }],
);
alertSpy.mockRestore();
});

it("renders the server insufficient-data status instead of inventing a classification", () => {
const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});

render(
<TrainingDistributionCards
intensityDistribution={null}
Expand Down Expand Up @@ -122,7 +164,21 @@ describe("TrainingDistributionCards", () => {
);

expect(screen.getByText("Insufficient data")).toBeTruthy();
expect(screen.getByText("Polarization needs time in every Treff zone.")).toBeTruthy();
expect(
screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
).toBeTruthy();
expect(screen.queryByText("Polarization needs time in every Treff zone.")).toBeNull();
fireEvent.click(
screen.getByRole("button", {
name: "About How this is calculated for Easy-to-hard training balance",
}),
);
expect(alertSpy).toHaveBeenCalledWith(
"How this is calculated for Easy-to-hard training balance",
expect.stringContaining("Polarization needs time in every Treff zone."),
[{ text: "Close" }],
);
alertSpy.mockRestore();
});

it("renders server-computed monotony inputs, descriptive method, and source", () => {
Expand Down Expand Up @@ -157,15 +213,16 @@ describe("TrainingDistributionCards", () => {
/>,
);

expect(screen.getByText("Training Monotony & Strain")).toBeTruthy();
expect(screen.getByText("Monotony 2.18")).toBeTruthy();
expect(screen.getByText("Strain 2460.0")).toBeTruthy();
expect(screen.getByText("Training variety and total load")).toBeTruthy();
expect(screen.getByText("Training variety 2.18")).toBeTruthy();
expect(screen.getByText("Weekly load strain 2460.0")).toBeTruthy();
expect(screen.getByText("Average daily load 161.14 · daily variation 73.92")).toBeTruthy();
expect(screen.queryByText(method.formula)).toBeNull();
expect(
screen.getByText("Daily mean 161.14 · population standard deviation (SD) 73.92"),
screen.getByRole("button", {
name: "About How this is calculated for Training variety and total load",
}),
).toBeTruthy();
expect(screen.getByText(method.formula)).toBeTruthy();
expect(screen.getByText(method.calendar)).toBeTruthy();
expect(screen.getByText(method.interpretation)).toBeTruthy();

fireEvent.click(screen.getByRole("link", { name: method.source.title }));
expect(openExternalUrl).toHaveBeenCalledWith(method.source.url, "training-monotony-source");
Expand Down
Loading
Loading