diff --git a/packages/mobile/app/(tabs)/index.stories.tsx b/packages/mobile/app/(tabs)/index.stories.tsx index 766674599d..c44fb6be8a 100644 --- a/packages/mobile/app/(tabs)/index.stories.tsx +++ b/packages/mobile/app/(tabs)/index.stories.tsx @@ -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"; @@ -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 } }, }); @@ -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, @@ -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, }, @@ -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"] : [], }, ); @@ -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 ( @@ -227,8 +246,8 @@ const meta = { layout: "fullscreen", }, decorators: [ - (Story) => ( - + (Story, context) => ( + @@ -242,3 +261,9 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const SleepDataNeeded: Story = { + parameters: { + sleepDataUnavailable: true, + }, +}; diff --git a/packages/mobile/app/(tabs)/index.test.tsx b/packages/mobile/app/(tabs)/index.test.tsx index 0cf87cd7c9..b0309042a4 100644 --- a/packages/mobile/app/(tabs)/index.test.tsx +++ b/packages/mobile/app/(tabs)/index.test.tsx @@ -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: { @@ -385,8 +385,10 @@ describe("TodayScreen independent loading states", () => { const { default: TodayScreen } = await import("./index"); render(); - 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(); diff --git a/packages/mobile/app/(tabs)/index.tsx b/packages/mobile/app/(tabs)/index.tsx index dc0c228c47..65cdc59a32 100644 --- a/packages/mobile/app/(tabs)/index.tsx +++ b/packages/mobile/app/(tabs)/index.tsx @@ -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({ @@ -274,7 +275,19 @@ export default function TodayScreen() { )} {/* Sleep summary */} - {!isLoading && ( + {!isLoading && isSleepDataMissing && ( + + + {sleepNeed.message} + + + )} + + {!isLoading && !isSleepDataMissing && ( - ) : ( - {sleepNeed.message} - )} + ) : null} )} diff --git a/packages/web/src/components/SleepOverviewCards.stories.tsx b/packages/web/src/components/SleepOverviewCards.stories.tsx new file mode 100644 index 0000000000..50f44bae01 --- /dev/null +++ b/packages/web/src/components/SleepOverviewCards.stories.tsx @@ -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) => ( +
+ +
+ ), + ], + args: { + sleepNeed: availableSleepNeed, + sleepPerformance, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/web/src/components/SleepOverviewCards.test.tsx b/packages/web/src/components/SleepOverviewCards.test.tsx new file mode 100644 index 0000000000..f5f4a500c4 --- /dev/null +++ b/packages/web/src/components/SleepOverviewCards.test.tsx @@ -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: () =>
, +})); + +vi.mock("./SleepPerformanceCard.tsx", () => ({ + SleepPerformanceCard: () =>
, +})); + +describe("SleepOverviewCards", () => { + it("shows one full-width prerequisite card when prior-night sleep is missing", () => { + render( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/packages/web/src/components/SleepOverviewCards.tsx b/packages/web/src/components/SleepOverviewCards.tsx new file mode 100644 index 0000000000..021ed12806 --- /dev/null +++ b/packages/web/src/components/SleepOverviewCards.tsx @@ -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 ( +
+ {!isSleepDataMissing && ( + + )} + +
+ ); +} diff --git a/packages/web/src/pages/SleepPage.tsx b/packages/web/src/pages/SleepPage.tsx index 3b812febb0..4289e64fca 100644 --- a/packages/web/src/pages/SleepPage.tsx +++ b/packages/web/src/pages/SleepPage.tsx @@ -14,8 +14,7 @@ import { ProcessingStatusWidget } from "../components/ProcessingStatusWidget.tsx import { QueryStatePanel } from "../components/QueryStatePanel.tsx"; import { SleepChart } from "../components/SleepChart.tsx"; import { SleepDataSourcesTable } from "../components/SleepDataSourcesTable.tsx"; -import { SleepNeedCard } from "../components/SleepNeedCard.tsx"; -import { SleepPerformanceCard } from "../components/SleepPerformanceCard.tsx"; +import { SleepOverviewCards } from "../components/SleepOverviewCards.tsx"; import { TimeRangeSelector } from "../components/TimeRangeSelector.tsx"; import { useProcessingStatus } from "../hooks/useProcessingStatus.ts"; import { useTodayQueryDate } from "../hooks/useTodayQueryDate.ts"; @@ -103,10 +102,12 @@ export function SleepPage() { )}
)} -
- - -
+ {/* Sleep Stage Chart */}