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
3 changes: 3 additions & 0 deletions packages/mobile/app/(tabs)/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ function createSeededProviders(sleepDataUnavailable: boolean) {
sleepNeed: sleepDataUnavailable
? {
availability: "missing_previous_night",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
message: MISSING_PREVIOUS_NIGHT_MESSAGE,
}
: {
availability: "available",
epistemicStatus: { kind: "estimated", label: "Estimated" },
baselineMinutes: 480,
strainDebtMinutes: 16,
accumulatedDebtMinutes: 28,
Expand Down Expand Up @@ -114,6 +116,7 @@ function createSeededProviders(sleepDataUnavailable: boolean) {
[["todayPlan", "get"], { input: { endDate: todayDate }, type: "query" }],
{
status: "ready",
epistemicStatus: { kind: "suggested", label: "Suggested" },
date: todayDate,
action: {
id: "strain_target",
Expand Down
23 changes: 23 additions & 0 deletions packages/mobile/app/(tabs)/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ describe("TodayScreen independent loading states", () => {
mockTodayPlanError = null;
mockTodayPlanData = {
status: "ready",
epistemicStatus: { kind: "suggested", label: "Suggested" },
date: "2026-03-21",
action: {
id: "strain_target",
Expand Down Expand Up @@ -226,6 +227,7 @@ describe("TodayScreen independent loading states", () => {
},
sleepNeed: {
availability: "available",
epistemicStatus: { kind: "estimated", label: "Estimated" },
baselineMinutes: 480,
strainDebtMinutes: 20,
accumulatedDebtMinutes: 10,
Expand Down Expand Up @@ -420,6 +422,7 @@ describe("TodayScreen independent loading states", () => {
},
sleepNeed: {
availability: "missing_previous_night",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
message: "Sync last night's sleep data to see tonight's sleep need.",
},
};
Expand All @@ -440,6 +443,25 @@ describe("TodayScreen independent loading states", () => {
expect(screen.queryByText("8h 37m")).toBeNull();
});

it("keeps a legacy cached sleep prerequisite visible without a status", async () => {
mockDashboardData = {
...mockDashboardData,
sleep: { lastNight: null, sleepDebt: 0 },
sleepNeed: {
availability: "missing_previous_night",
message: "Sync last night's sleep data to see tonight's sleep need.",
},
};

const { default: TodayScreen } = await import("./index");
render(<TodayScreen />);

expect(
screen.getByText("Sync last night's sleep data to see tonight's sleep need."),
).toBeTruthy();
expect(screen.queryByText("Unavailable")).toBeNull();
});

it("renders the server-authored next action for insufficient sleep need data", async () => {
mockDashboardData = {
...mockDashboardData,
Expand All @@ -457,6 +479,7 @@ describe("TodayScreen independent loading states", () => {
},
sleepNeed: {
availability: "insufficient_data",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
reason: "insufficient_baseline_history",
message: "Sync at least 7 qualifying nights to estimate sleep need.",
nextAction: "Sync more sleep and recovery data.",
Expand Down
3 changes: 3 additions & 0 deletions packages/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export default function TodayScreen() {
.easing(Easing.bezier(0.16, 1, 0.3, 1))}
>
<Card title="Sleep Data Needed">
<Text style={styles.sleepNeedMissing}>{sleepNeed.epistemicStatus?.label}</Text>
<Text style={styles.sleepNeedMissing}>{sleepNeed.message}</Text>
</Card>
</Animated.View>
Expand Down Expand Up @@ -340,6 +341,7 @@ export default function TodayScreen() {
<Text style={styles.noDataText}>No sleep data</Text>
) : sleepNeed.availability === "available" ? (
<>
<Text style={styles.sleepNeedSubtitle}>{sleepNeed.epistemicStatus.label}</Text>
<Text style={styles.sleepNeedTotal}>
{`${sleepNeed.estimateMetadata.valueQualifier} ${formatDurationMinutes(sleepNeed.totalNeedMinutes)}`}
</Text>
Expand Down Expand Up @@ -392,6 +394,7 @@ export default function TodayScreen() {
</>
) : (
<>
<Text style={styles.sleepNeedMissing}>{sleepNeed.epistemicStatus.label}</Text>
<Text style={styles.noDataText}>{sleepNeed.message}</Text>
<Text style={styles.sleepNeedMissing}>{sleepNeed.nextAction}</Text>
</>
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/app/(tabs)/recovery.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ function createSeededProviders(healthspanInsufficient = false, recoveryUnavailab
weight: trendDates.map((date, index) => ({
date,
rawWeight: 74.2 - index * 0.05,
rawWeightStatus: { kind: "observed", label: "Observed" },
smoothedWeight: 74.2 - index * 0.05,
smoothedWeightStatus: { kind: "estimated", label: "Estimated" },
weeklyChange: -0.2,
interpolated: false,
})),
Expand Down
36 changes: 35 additions & 1 deletion packages/mobile/app/(tabs)/recovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -616,14 +616,18 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => {
{
date: "2026-04-05",
rawWeight: 80.2,
rawWeightStatus: { kind: "observed", label: "Observed" },
smoothedWeight: 79.8,
smoothedWeightStatus: { kind: "estimated", label: "Estimated" },
weeklyChange: null,
interpolated: false,
},
{
date: "2026-04-06",
rawWeight: 80,
rawWeightStatus: { kind: "observed", label: "Observed" },
smoothedWeight: 79.8,
smoothedWeightStatus: { kind: "estimated", label: "Estimated" },
weeklyChange: null,
interpolated: false,
},
Expand All @@ -636,12 +640,40 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => {

expect(screen.getByText("TREND WEIGHT")).toBeTruthy();
expect(screen.getByText("79.8 kg")).toBeTruthy();
expect(screen.getByText("Scale: 80.0 kg")).toBeTruthy();
expect(screen.getByText("Estimated")).toBeTruthy();
expect(screen.getByText("Observed: 80.0 kg")).toBeTruthy();
expect(
screen.getByText("Moves 10% toward each day's scale weight; gaps are interpolated."),
).toBeTruthy();
});

it("keeps a legacy cached weight row visible without epistemic statuses", async () => {
mockRecoveryData = {
hrvVariability: [],
hrvBaseline: [],
readinessScore: [],
stress: { daily: [], weekly: [], latestScore: null, trend: "stable" },
trends: null,
dailyMetrics: [],
weight: [
{
date: "2026-04-06",
rawWeight: 80,
smoothedWeight: 79.8,
weeklyChange: null,
interpolated: false,
},
],
healthspan: insufficientHealthspan,
};

const { default: RecoveryScreen } = await import("./recovery");
render(<RecoveryScreen />);

expect(screen.getByText("79.8 kg")).toBeTruthy();
expect(screen.queryByText("Estimated")).toBeNull();
});

it("uses neutral text for weight-rate direction", async () => {
mockRecoveryData = {
hrvVariability: [],
Expand All @@ -654,7 +686,9 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => {
{
date: "2026-04-06",
rawWeight: 80,
rawWeightStatus: { kind: "observed", label: "Observed" },
smoothedWeight: 79.8,
smoothedWeightStatus: { kind: "estimated", label: "Estimated" },
weeklyChange: null,
interpolated: false,
},
Expand Down
6 changes: 4 additions & 2 deletions packages/mobile/app/(tabs)/recovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,9 +614,11 @@ export default function RecoveryScreen() {
<Text style={styles.weightValue}>
{formatMeasurementText(units.formatWeight(latestWeight.smoothedWeight))}
</Text>
{latestWeight.rawWeight != null && (
<Text style={styles.weightScale}>{latestWeight.smoothedWeightStatus?.label}</Text>
{latestWeight.rawWeight != null && latestWeight.rawWeightStatus != null && (
<Text style={styles.weightScale}>
Scale: {formatMeasurementText(units.formatWeight(latestWeight.rawWeight))}
{latestWeight.rawWeightStatus.label}:{" "}
{formatMeasurementText(units.formatWeight(latestWeight.rawWeight))}
</Text>
)}
<Text style={styles.weightExplanation}>
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/app/correlation.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const metrics = [
const availableResult = {
analysisVersion: 2,
availability: "available",
epistemicStatus: { kind: "associated", label: "Associated" },
spearmanRho: 0.72,
regression: { slope: 0.42, intercept: 8, rSquared: 0.46 },
dataPoints: [
Expand Down Expand Up @@ -73,6 +74,7 @@ const availableResult = {
const insufficientResult = {
analysisVersion: 2,
availability: "insufficient",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
dataPoints: [],
sampleCount: 0,
additionalSamplesRequired: 5,
Expand Down
18 changes: 18 additions & 0 deletions packages/mobile/app/correlation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("CorrelationScreen", () => {
state.correlationData = {
analysisVersion: 2,
availability: "insufficient",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
dataPoints: [],
sampleCount: 0,
additionalSamplesRequired: 5,
Expand Down Expand Up @@ -243,6 +244,7 @@ describe("CorrelationScreen", () => {
render(<CorrelationScreen />);

expect(screen.getByText("n = 0")).toBeTruthy();
expect(screen.getByText("Unavailable")).toBeTruthy();
expect(screen.getByText("5 more paired calendar days needed")).toBeTruthy();
expect(screen.getByText("90 selected")).toBeTruthy();
expect(screen.getByText("90 missing pairs")).toBeTruthy();
Expand All @@ -256,6 +258,18 @@ describe("CorrelationScreen", () => {
expect(screen.queryByText(/^p =/)).toBeNull();
});

it("renders a legacy cached result without an epistemic status", async () => {
const legacyCorrelationData = { ...(state.correlationData ?? {}) };
delete legacyCorrelationData.epistemicStatus;
state.correlationData = legacyCorrelationData;

const { default: CorrelationScreen } = await import("./correlation");
render(<CorrelationScreen />);

expect(screen.getByText("n = 0")).toBeTruthy();
expect(screen.queryByText("Unavailable")).toBeNull();
});

it("uses singular sample wording when one additional sample is required", async () => {
state.correlationData = {
...state.correlationData,
Expand All @@ -274,6 +288,7 @@ describe("CorrelationScreen", () => {
state.correlationData = {
analysisVersion: 2,
availability: "available",
epistemicStatus: { kind: "associated", label: "Associated" },
spearmanRho: 0.75,
regression: { slope: 1, intercept: 0, rSquared: 0.49 },
dataPoints: [],
Expand Down Expand Up @@ -306,6 +321,7 @@ describe("CorrelationScreen", () => {
render(<CorrelationScreen />);

expect(screen.getByText("Spearman rho = +0.75")).toBeTruthy();
expect(screen.getByText("Associated")).toBeTruthy();
expect(screen.getByText("95% block-bootstrap interval: +0.42 to +0.86")).toBeTruthy();
expect(screen.getByText("Slope = 1.000 ms per g")).toBeTruthy();
expect(screen.getByText("R² = 0.490")).toBeTruthy();
Expand All @@ -319,6 +335,7 @@ describe("CorrelationScreen", () => {
state.correlationData = {
analysisVersion: 2,
availability: "available",
epistemicStatus: { kind: "associated", label: "Associated" },
spearmanRho: -0.75,
regression: { slope: -1, intercept: 3, rSquared: 0.49 },
dataPoints: [
Expand Down Expand Up @@ -372,6 +389,7 @@ describe("CorrelationScreen", () => {
state.correlationData = {
analysisVersion: 2,
availability: "available",
epistemicStatus: { kind: "associated", label: "Associated" },
spearmanRho: 0.75,
regression: { slope: 1, intercept: 0, rSquared: 0.49 },
dataPoints: [
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/app/correlation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ export default function CorrelationScreen() {
description="The rank correlation, dependence-aware interval, and calendar-day coverage for the selected metrics."
textStyle={styles.cardTitle}
/>
<Text style={styles.statText}>{data.epistemicStatus?.label}</Text>

{data.availability === "available" ? (
<>
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/components/TodayPlanCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TodayPlanCard } from "./TodayPlanCard";

const readyPlan: TodayPlanResult = {
status: "ready",
epistemicStatus: { kind: "suggested", label: "Suggested" },
date: "2026-07-26",
action: {
id: "strain_target",
Expand All @@ -26,6 +27,7 @@ const readyPlan: TodayPlanResult = {

const insufficientPlan: TodayPlanResult = {
status: "insufficient_data",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
date: "2026-07-26",
action: null,
supportingFacts: [],
Expand Down
22 changes: 22 additions & 0 deletions packages/mobile/components/TodayPlanCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TodayPlanCard } from "./TodayPlanCard";

const readyPlan: TodayPlanResult = {
status: "ready",
epistemicStatus: { kind: "suggested", label: "Suggested" },
date: "2026-07-26",
action: {
id: "strain_target",
Expand All @@ -27,6 +28,7 @@ const readyPlan: TodayPlanResult = {

const insufficientPlan: TodayPlanResult = {
status: "insufficient_data",
epistemicStatus: { kind: "unavailable", label: "Unavailable" },
date: "2026-07-26",
action: null,
supportingFacts: [],
Expand Down Expand Up @@ -60,6 +62,7 @@ describe("TodayPlanCard", () => {
expect(screen.getByText("Sleep performance")).toBeTruthy();
expect(screen.getByText("88 (Good)")).toBeTruthy();
expect(screen.getByText("High confidence")).toBeTruthy();
expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText(/Recovery data from 2026-07-26/)).toBeTruthy();
expect(screen.getByText(/Sleep data from 2026-07-26/)).toBeTruthy();
});
Expand All @@ -86,6 +89,7 @@ describe("TodayPlanCard", () => {
"Connect a recovery source and wait for today's recovery score before a training plan can be generated.",
),
).toBeTruthy();
expect(screen.getByText("Unavailable")).toBeTruthy();
});

it("renders a loading state", () => {
Expand All @@ -106,4 +110,22 @@ describe("TodayPlanCard", () => {
expect(screen.getByText("Train hard today — aim for 16.2 strain")).toBeTruthy();
expect(screen.getByText("Today plan refresh failed")).toBeTruthy();
});

it("keeps a legacy cached ready plan visible without a status", () => {
const legacyPlan = { ...readyPlan };
Reflect.deleteProperty(legacyPlan, "epistemicStatus");
render(<TodayPlanCard plan={legacyPlan} />);

expect(screen.getByText("Train hard today — aim for 16.2 strain")).toBeTruthy();
expect(screen.queryByText("Suggested")).toBeNull();
});

it("keeps a legacy cached insufficient plan visible without a status", () => {
const legacyPlan = { ...insufficientPlan };
Reflect.deleteProperty(legacyPlan, "epistemicStatus");
render(<TodayPlanCard plan={legacyPlan} />);

expect(screen.getByText("WHAT MATTERS TODAY")).toBeTruthy();
expect(screen.queryByText("Unavailable")).toBeNull();
});
});
2 changes: 2 additions & 0 deletions packages/mobile/components/TodayPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro
<Text style={styles.sectionTitle}>WHAT MATTERS TODAY</Text>
{refreshWarning}
<Text style={styles.message}>{plan.message}</Text>
<Text style={styles.meta}>{plan.epistemicStatus?.label}</Text>
<Text style={styles.meta}>{formatTodayPlanConfidence(plan.confidence)}</Text>
</View>
);
Expand Down Expand Up @@ -106,6 +107,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro
) : null}
</View>
) : null}
<Text style={styles.meta}>{plan.epistemicStatus?.label}</Text>
<Text style={styles.meta}>{formatTodayPlanConfidence(plan.confidence)}</Text>
{freshness != null ? <Text style={styles.meta}>{freshness}</Text> : null}
</View>
Expand Down
Loading
Loading