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
87 changes: 56 additions & 31 deletions packages/mobile/app/(tabs)/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { formatDateYmd } from "@dofek/format/format";
import { PROVIDER_GUIDE_SETTINGS_KEY } from "@dofek/onboarding/provider-guide";
import type { Meta, StoryObj } from "@storybook/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MISSING_PREVIOUS_NIGHT_MESSAGE } from "dofek-server/sleep-need-contract";
import { type ReactNode, useMemo } from "react";
import { View } from "react-native";
import { trpc } from "../../lib/trpc";
Expand All @@ -18,7 +19,7 @@ function localDateString(dayOffset = 0): string {
return formatDateYmd(date);
}

function createSeededProviders() {
function createSeededProviders(sleepDataUnavailable: boolean) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
Expand Down Expand Up @@ -48,15 +49,17 @@ function createSeededProviders() {
weights: { hrv: 0.5, restingHr: 0.2, sleep: 0.15, respiratoryRate: 0.15 },
},
sleep: {
lastNight: {
date: localDateString(-1),
durationMinutes: 456,
deepPct: 21,
remPct: 24,
lightPct: 47,
awakePct: 8,
},
sleepDebt: 18,
lastNight: sleepDataUnavailable
? null
: {
date: localDateString(-1),
durationMinutes: 456,
deepPct: 21,
remPct: 24,
lightPct: 47,
awakePct: 8,
},
sleepDebt: sleepDataUnavailable ? 0 : 18,
},
strain: {
dailyStrain: 11.8,
Expand All @@ -65,15 +68,20 @@ function createSeededProviders() {
workloadRatio: 0.91,
date: todayDate,
},
sleepNeed: {
availability: "available",
baselineMinutes: 480,
strainDebtMinutes: 16,
accumulatedDebtMinutes: 28,
debtRecoveryMinutes: 7,
totalNeedMinutes: 503,
recentNights: [],
},
sleepNeed: sleepDataUnavailable
? {
availability: "missing_previous_night",
message: MISSING_PREVIOUS_NIGHT_MESSAGE,
}
: {
availability: "available",
baselineMinutes: 480,
strainDebtMinutes: 16,
accumulatedDebtMinutes: 28,
debtRecoveryMinutes: 7,
totalNeedMinutes: 503,
recentNights: [],
},
anomalies: { anomalies: [], checkedMetrics: [] },
latestDate: todayDate,
},
Expand All @@ -90,16 +98,21 @@ function createSeededProviders() {
summary: "Recovery is strong (82). Push for a high-strain day to build fitness.",
zone: "Push",
},
supportingFacts: [
{ label: "Recovery", value: "82/100" },
{ label: "Sleep performance", value: "88 (Good)" },
],
confidence: "high",
supportingFacts: sleepDataUnavailable
? [
{ label: "Recovery", value: "82/100" },
{ label: "Recent-to-baseline workload ratio", value: "0.91" },
]
: [
{ label: "Recovery", value: "82/100" },
{ label: "Sleep performance", value: "88 (Good)" },
],
confidence: sleepDataUnavailable ? "moderate" : "high",
freshness: {
recoveryDate: todayDate,
sleepDate: localDateString(-1),
sleepDate: sleepDataUnavailable ? null : localDateString(-1),
},
missingInputs: [],
missingInputs: sleepDataUnavailable ? ["sleep"] : [],
},
);

Expand Down Expand Up @@ -202,16 +215,22 @@ function createSeededProviders() {
return { processingStatus, queryClient };
}

function MockProviders({ children }: { children: ReactNode }) {
function MockProviders({
children,
sleepDataUnavailable,
}: {
children: ReactNode;
sleepDataUnavailable: boolean;
}) {
const { queryClient, trpcClient } = useMemo(() => {
const seededProviders = createSeededProviders();
const seededProviders = createSeededProviders(sleepDataUnavailable);
return {
...seededProviders,
trpcClient: trpc.createClient({
links: [createProcessingStatusStoryLink(seededProviders.processingStatus)],
}),
};
}, []);
}, [sleepDataUnavailable]);

return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
Expand All @@ -227,8 +246,8 @@ const meta = {
layout: "fullscreen",
},
decorators: [
(Story) => (
<MockProviders>
(Story, context) => (
<MockProviders sleepDataUnavailable={context.parameters.sleepDataUnavailable === true}>
<View style={{ minHeight: 1200, backgroundColor: colors.background }}>
<Story />
</View>
Expand All @@ -242,3 +261,9 @@ export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const SleepDataNeeded: Story = {
parameters: {
sleepDataUnavailable: true,
},
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
};
8 changes: 5 additions & 3 deletions packages/mobile/app/(tabs)/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ describe("TodayScreen independent loading states", () => {
expect(screen.getByText("LAST NIGHT")).toBeTruthy();
});

it("shows the server availability message without recommendation values when prior sleep is missing", async () => {
it("shows one sleep-data prerequisite card when prior sleep is missing", async () => {
mockDashboardData = {
...mockDashboardData,
sleep: {
Expand All @@ -385,8 +385,10 @@ describe("TodayScreen independent loading states", () => {
const { default: TodayScreen } = await import("./index");
render(<TodayScreen />);

expect(screen.getByText("LAST NIGHT")).toBeTruthy();
expect(screen.getByText("No sleep data")).toBeTruthy();
expect(screen.getByText("SLEEP DATA NEEDED")).toBeTruthy();
expect(screen.queryByText("LAST NIGHT")).toBeNull();
expect(screen.queryByText("SLEEP COACH")).toBeNull();
expect(screen.queryByText("No sleep data")).toBeNull();
expect(
screen.getByText("Sync last night's sleep data to see tonight's sleep need."),
).toBeTruthy();
Expand Down
21 changes: 16 additions & 5 deletions packages/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default function TodayScreen() {

// Alerts and sleep guidance from consolidated query
const sleepNeed = dashboardData?.sleepNeed;
const isSleepDataMissing = sleepNeed?.availability === "missing_previous_night";
const anomalies = anomalyQuery.data ?? dashboardData?.anomalies;

const isLoading = shouldShowBlockingLoading({
Expand Down Expand Up @@ -274,7 +275,19 @@ export default function TodayScreen() {
)}

{/* Sleep summary */}
{!isLoading && (
{!isLoading && isSleepDataMissing && (
<Animated.View
entering={FadeInUp.delay(160)
.duration(duration.slow)
.easing(Easing.bezier(0.16, 1, 0.3, 1))}
>
<Card title="Sleep Data Needed">
<Text style={styles.sleepNeedMissing}>{sleepNeed.message}</Text>
</Card>
</Animated.View>
)}

{!isLoading && !isSleepDataMissing && (
<Animated.View
entering={FadeInUp.delay(160)
.duration(duration.slow)
Expand Down Expand Up @@ -309,7 +322,7 @@ export default function TodayScreen() {
)}

{/* Sleep Coach */}
{!isLoading && (sleepNeed || !lastNight) && (
{!isLoading && !isSleepDataMissing && (sleepNeed || !lastNight) && (
<Animated.View
entering={FadeInUp.delay(320)
.duration(duration.slow)
Expand Down Expand Up @@ -345,9 +358,7 @@ export default function TodayScreen() {
</View>
</View>
</>
) : (
<Text style={styles.sleepNeedMissing}>{sleepNeed.message}</Text>
)}
) : null}
</Card>
</Animated.View>
)}
Expand Down
75 changes: 75 additions & 0 deletions packages/web/src/components/SleepOverviewCards.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MISSING_PREVIOUS_NIGHT_MESSAGE } from "dofek-server/sleep-need-contract";
import { SleepOverviewCards } from "./SleepOverviewCards";

const availableSleepNeed = {
availability: "available" as const,
baselineMinutes: 480,
strainDebtMinutes: 12,
accumulatedDebtMinutes: 85,
debtRecoveryMinutes: 21,
totalNeedMinutes: 513,
recentNights: [],
};

const sleepPerformance = {
score: 88,
tier: "Good" as const,
actualMinutes: 462,
neededMinutes: 480,
efficiency: 92,
recommendedBedtime: "10:30 PM",
sleepDate: "2026-04-02",
providerId: "whoop",
sourceName: "WHOOP 4.0",
sourceProviders: ["whoop"],
};

const meta = {
title: "Sleep/SleepOverviewCards",
component: SleepOverviewCards,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "100%", maxWidth: 900 }}>
<Story />
</div>
),
],
args: {
sleepNeed: availableSleepNeed,
sleepPerformance,
},
} satisfies Meta<typeof SleepOverviewCards>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Loading: Story = {
args: {
sleepNeed: undefined,
sleepNeedLoading: true,
sleepPerformance: undefined,
sleepPerformanceLoading: true,
},
};

export const NoData: Story = {
args: {
sleepNeed: undefined,
sleepPerformance: null,
},
};

export const SleepDataNeeded: Story = {
args: {
sleepNeed: {
availability: "missing_previous_night",
message: MISSING_PREVIOUS_NIGHT_MESSAGE,
},
sleepPerformance: null,
},
};
53 changes: 53 additions & 0 deletions packages/web/src/components/SleepOverviewCards.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @vitest-environment jsdom */

import { render, screen } from "@testing-library/react";
import { MISSING_PREVIOUS_NIGHT_MESSAGE } from "dofek-server/sleep-need-contract";
import { describe, expect, it, vi } from "vitest";
import { SleepOverviewCards } from "./SleepOverviewCards.tsx";

vi.mock("./SleepNeedCard.tsx", () => ({
SleepNeedCard: () => <div data-testid="sleep-need-card" />,
}));

vi.mock("./SleepPerformanceCard.tsx", () => ({
SleepPerformanceCard: () => <div data-testid="sleep-performance-card" />,
}));

describe("SleepOverviewCards", () => {
it("shows one full-width prerequisite card when prior-night sleep is missing", () => {
render(
<SleepOverviewCards
sleepNeed={{
availability: "missing_previous_night",
message: MISSING_PREVIOUS_NIGHT_MESSAGE,
}}
sleepPerformance={null}
/>,
);

expect(screen.getByTestId("sleep-overview-cards").className).not.toContain("lg:grid-cols-2");
expect(screen.getByTestId("sleep-need-card")).toBeDefined();
expect(screen.queryByTestId("sleep-performance-card")).toBeNull();
});

it("shows both sleep cards when the recommendation is available", () => {
render(
<SleepOverviewCards
sleepNeed={{
availability: "available",
baselineMinutes: 480,
strainDebtMinutes: 12,
accumulatedDebtMinutes: 85,
debtRecoveryMinutes: 21,
totalNeedMinutes: 513,
recentNights: [],
}}
sleepPerformance={null}
/>,
);

expect(screen.getByTestId("sleep-overview-cards").className).toContain("lg:grid-cols-2");
expect(screen.getByTestId("sleep-need-card")).toBeDefined();
expect(screen.getByTestId("sleep-performance-card")).toBeDefined();
});
});
34 changes: 34 additions & 0 deletions packages/web/src/components/SleepOverviewCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { SleepNeedV2 } from "dofek-server/sleep-need-contract";
import type { SleepPerformanceInfo } from "dofek-server/types";
import { SleepNeedCard } from "./SleepNeedCard.tsx";
import { SleepPerformanceCard } from "./SleepPerformanceCard.tsx";

interface SleepOverviewCardsProps {
sleepNeed: SleepNeedV2 | undefined;
sleepNeedLoading?: boolean;
sleepPerformance: SleepPerformanceInfo | null | undefined;
sleepPerformanceLoading?: boolean;
}

export function SleepOverviewCards({
sleepNeed,
sleepNeedLoading,
sleepPerformance,
sleepPerformanceLoading,
}: SleepOverviewCardsProps) {
const isSleepDataMissing = sleepNeed?.availability === "missing_previous_night";

return (
<div
className={
isSleepDataMissing ? "grid grid-cols-1 gap-4" : "grid grid-cols-1 lg:grid-cols-2 gap-4"
}
data-testid="sleep-overview-cards"
>
{!isSleepDataMissing && (
<SleepPerformanceCard data={sleepPerformance} loading={sleepPerformanceLoading} />
)}
<SleepNeedCard data={sleepNeed} loading={sleepNeedLoading} />
</div>
);
}
Loading
Loading