diff --git a/packages/mobile/.storybook/main.ts b/packages/mobile/.storybook/main.ts index bdfcb66510..e2bdd4e4fd 100644 --- a/packages/mobile/.storybook/main.ts +++ b/packages/mobile/.storybook/main.ts @@ -19,6 +19,10 @@ const config: StorybookConfig = { : {}; viteConfig.resolve.alias = { ...existingAliases, + "@react-native-community/datetimepicker": resolve( + currentDir, + "./mocks/react-native-community-datetimepicker.tsx", + ), "expo-modules-core": resolve(currentDir, "./mocks/expo-modules-core.ts"), "expo-router": resolve(currentDir, "./mocks/expo-router.ts"), "expo-updates": resolve(currentDir, "./mocks/expo-updates.ts"), diff --git a/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.test.tsx b/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.test.tsx new file mode 100644 index 0000000000..6d7a846563 --- /dev/null +++ b/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import StorybookDateTimePicker from "./react-native-community-datetimepicker"; + +describe("StorybookDateTimePicker", () => { + it("lets the story exercise date selection", () => { + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Period start date" })); + + expect(onChange).toHaveBeenCalledWith( + { + type: "set", + nativeEvent: { + timestamp: new Date(2026, 6, 26, 12).getTime(), + utcOffset: new Date(2026, 6, 26, 12).getTimezoneOffset(), + }, + }, + new Date(2026, 6, 26, 12), + ); + }); +}); diff --git a/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.tsx b/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.tsx new file mode 100644 index 0000000000..7bd502fa5a --- /dev/null +++ b/packages/mobile/.storybook/mocks/react-native-community-datetimepicker.tsx @@ -0,0 +1,69 @@ +import { Pressable, StyleSheet, Text } from "react-native"; +import { colors, fontSize, radius, spacing } from "../../theme"; + +interface StorybookDateTimePickerEvent { + type: "set"; + nativeEvent: { + timestamp: number; + utcOffset: number; + }; +} + +interface StorybookDateTimePickerProps { + accessibilityLabel?: string; + onChange?: (event: StorybookDateTimePickerEvent, date?: Date) => void; + value: Date; +} + +export default function StorybookDateTimePicker({ + accessibilityLabel, + onChange, + value, +}: StorybookDateTimePickerProps) { + const selectPreviousDay = () => { + const selectedDate = new Date(value); + selectedDate.setDate(selectedDate.getDate() - 1); + onChange?.( + { + type: "set", + nativeEvent: { + timestamp: selectedDate.getTime(), + utcOffset: selectedDate.getTimezoneOffset(), + }, + }, + selectedDate, + ); + }; + + return ( + + + {value.toLocaleDateString("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + })} + + + ); +} + +const styles = StyleSheet.create({ + picker: { + backgroundColor: colors.surfaceSecondary, + borderColor: colors.border, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + text: { + color: colors.text, + fontSize: fontSize.base, + }, +}); diff --git a/packages/mobile/app/cycle.stories.tsx b/packages/mobile/app/cycle.stories.tsx new file mode 100644 index 0000000000..e6ab0ab6e3 --- /dev/null +++ b/packages/mobile/app/cycle.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { OperationResultObservable, TRPCLink } from "@trpc/client"; +import type { AppRouter } from "dofek-server/router"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { trpc } from "../lib/trpc"; +import { colors } from "../theme"; +import CycleScreen from "./cycle"; + +const currentPhase = { + phase: "menstrual" as const, + dayOfCycle: 3, + cycleLength: 28, +}; + +const periodHistory = [ + { + id: "11111111-1111-4111-8111-111111111111", + startDate: "2026-07-01", + endDate: "2026-07-05", + durationDays: 5, + durationLabel: "5 days", + notes: null, + }, + { + id: "22222222-2222-4222-8222-222222222222", + startDate: "2026-06-03", + endDate: "2026-06-07", + durationDays: 5, + durationLabel: "5 days", + notes: null, + }, +]; + +function createMockLink(): TRPCLink { + return () => + ({ op }) => + createMockObservable(op.path); +} + +function createMockObservable(path: string): OperationResultObservable { + const result: OperationResultObservable = { + subscribe(observer) { + if (path === "menstrualCycle.currentPhase") { + observer.next?.({ result: { data: currentPhase } }); + } else if (path === "menstrualCycle.history") { + observer.next?.({ result: { data: periodHistory } }); + } else if (path === "menstrualCycle.logPeriod") { + observer.next?.({ + result: { + data: { + id: "33333333-3333-4333-8333-333333333333", + startDate: "2026-07-27", + endDate: null, + durationDays: null, + durationLabel: null, + notes: null, + }, + }, + }); + } else { + throw new Error(`Unhandled cycle story tRPC operation: ${path}`); + } + observer.complete?.(); + return { unsubscribe() {} }; + }, + pipe() { + return result; + }, + }; + return result; +} + +function CycleStoryFrame() { + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + // Story fixtures are immutable, so background refetches should not replace them. + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }), + [], + ); + const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink()] }), []); + + return ( + + + + + + + + ); +} + +const meta = { + title: "Pages/CycleTracking", + component: CycleScreen, + parameters: { + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const CurrentPhase: Story = { + render: () => , +}; diff --git a/packages/mobile/app/cycle.test.tsx b/packages/mobile/app/cycle.test.tsx index 48789c3f70..4813658355 100644 --- a/packages/mobile/app/cycle.test.tsx +++ b/packages/mobile/app/cycle.test.tsx @@ -164,6 +164,23 @@ describe("CycleScreen", () => { expect(screen.queryByText(/No active cycle detected/)).toBeNull(); }); + it("shows the tracking-only safety boundary beside the phase estimate", async () => { + state.phaseQuery.data = { + phase: "menstrual", + dayOfCycle: 3, + cycleLength: 28, + }; + const { default: CycleScreen } = await import("./cycle"); + + render(); + + expect( + screen.getByLabelText( + "Cycle tracking safety notice. Tracking estimates only. Do not use for birth control or diagnosis.", + ), + ).toBeTruthy(); + }); + it("renders the server-provided duration label", async () => { state.historyQuery.data = [ { diff --git a/packages/mobile/app/cycle.tsx b/packages/mobile/app/cycle.tsx index ddf811a733..7aeb60e675 100644 --- a/packages/mobile/app/cycle.tsx +++ b/packages/mobile/app/cycle.tsx @@ -1,5 +1,5 @@ import { formatDateYmd } from "@dofek/format/format"; -import { PHASE_DISPLAY } from "@dofek/scoring/menstrual-cycle"; +import { CYCLE_TRACKING_SAFETY_NOTICE, PHASE_DISPLAY } from "@dofek/scoring/menstrual-cycle"; import DateTimePicker from "@react-native-community/datetimepicker"; import { Stack } from "expo-router"; import { useState } from "react"; @@ -7,7 +7,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { getQueryErrorMessage, QueryStatePanel } from "../components/QueryStatePanel"; import { captureException } from "../lib/telemetry"; import { trpc } from "../lib/trpc"; -import { colors } from "../theme"; +import { colors, fontSize, fontWeight, radius, spacing } from "../theme"; import { rootStackScreenOptions } from "./_layout-options"; function localDateFromYmd(value: string): Date { @@ -85,6 +85,14 @@ export default function CycleScreen() { minHeight={72} /> ) : null} + + Tracking limitation + {CYCLE_TRACKING_SAFETY_NOTICE} + @@ -217,6 +225,25 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 2, }, + safetyNotice: { + backgroundColor: colors.surfaceSecondary, + borderColor: colors.border, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + gap: spacing.xs, + marginTop: spacing.sm, + padding: spacing.md, + }, + safetyNoticeTitle: { + color: colors.text, + fontSize: fontSize.base, + fontWeight: fontWeight.semibold, + }, + safetyNoticeText: { + color: colors.textSecondary, + fontSize: fontSize.base, + lineHeight: 20, + }, emptyText: { color: colors.textTertiary, fontSize: 14, diff --git a/packages/scoring/README.md b/packages/scoring/README.md index 9f456122d8..06311e0450 100644 --- a/packages/scoring/README.md +++ b/packages/scoring/README.md @@ -42,7 +42,7 @@ console.log({ | `@dofek/scoring/today-plan` | Deterministic ready/insufficient-data Today Plan result with a primary action, supporting facts, confidence, freshness, and shared presentation helpers | | `@dofek/scoring/sleep-performance` | Sleep-performance components, tiers, and recommended-bedtime calculation | | `@dofek/scoring/healthspan-years` | Score-to-years mapping and formatting | -| `@dofek/scoring/menstrual-cycle` | Cycle-phase estimation and display metadata | +| `@dofek/scoring/menstrual-cycle` | Cycle-phase estimation, display metadata, and shared safety copy | | `@dofek/scoring/breathwork` | Built-in breathing techniques and session-duration helpers | | `@dofek/scoring/loading-policy` | Blocking-loading state policy | | `@dofek/scoring/query-cache` | Shared query-cache age constant | @@ -60,7 +60,9 @@ console.log({ components equally. - Healthspan display deltas map scores from 0–100 onto +3 to -2 years. - Cycle phases estimate ovulation as `cycleLength - 14`; this is a display - estimate, not a clinical assessment. + estimate, not a clinical assessment. The shared safety notice follows + [Apple's Cycle Tracking limitation](https://support.apple.com/en-au/120356) + that these estimates must not be used for birth control or diagnosis. - The design-token modules contain values only; they do not install fonts or render UI. diff --git a/packages/scoring/src/menstrual-cycle.test.ts b/packages/scoring/src/menstrual-cycle.test.ts index a2ab3c1b90..b2a85eb8de 100644 --- a/packages/scoring/src/menstrual-cycle.test.ts +++ b/packages/scoring/src/menstrual-cycle.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { type CyclePhase, computePhase } from "./menstrual-cycle.ts"; +import { CYCLE_TRACKING_SAFETY_NOTICE, type CyclePhase, computePhase } from "./menstrual-cycle.ts"; + +describe("CYCLE_TRACKING_SAFETY_NOTICE", () => { + it("defines the shared tracking-only boundary", () => { + expect(CYCLE_TRACKING_SAFETY_NOTICE).toBe( + "Tracking estimates only. Do not use for birth control or diagnosis.", + ); + }); +}); describe("computePhase", () => { it("returns menstrual phase for days 1-5", () => { diff --git a/packages/scoring/src/menstrual-cycle.ts b/packages/scoring/src/menstrual-cycle.ts index ff968c2a11..6b265a4ee5 100644 --- a/packages/scoring/src/menstrual-cycle.ts +++ b/packages/scoring/src/menstrual-cycle.ts @@ -14,6 +14,9 @@ import { chartColors, statusColors } from "./colors.ts"; export type CyclePhase = "menstrual" | "follicular" | "ovulatory" | "luteal"; +export const CYCLE_TRACKING_SAFETY_NOTICE = + "Tracking estimates only. Do not use for birth control or diagnosis."; + /** * Compute the cycle phase for a given day within a cycle. * @param dayOfCycle 1-based day number within the cycle diff --git a/packages/web/src/routes/cycle.test.tsx b/packages/web/src/routes/cycle.test.tsx index 689578d7ab..3e7ca86ee8 100644 --- a/packages/web/src/routes/cycle.test.tsx +++ b/packages/web/src/routes/cycle.test.tsx @@ -150,6 +150,20 @@ describe("CyclePage", () => { expect(screen.getByText("Period history could not be loaded.")).toBeTruthy(); }); + it("shows the tracking-only safety boundary beside the phase estimate", () => { + state.phaseQuery.data = { + phase: "menstrual", + dayOfCycle: 3, + cycleLength: 28, + }; + + renderCyclePage(); + + expect(screen.getByRole("note", { name: "Cycle tracking safety notice" })).toHaveTextContent( + "Tracking estimates only. Do not use for birth control or diagnosis.", + ); + }); + it("renders the server-provided duration label", () => { state.historyQuery.data = [ { diff --git a/packages/web/src/routes/cycle.tsx b/packages/web/src/routes/cycle.tsx index 8e93ef6e2b..cc13ae4dc1 100644 --- a/packages/web/src/routes/cycle.tsx +++ b/packages/web/src/routes/cycle.tsx @@ -1,5 +1,5 @@ import { formatDateYmd } from "@dofek/format/format"; -import { PHASE_DISPLAY } from "@dofek/scoring/menstrual-cycle"; +import { CYCLE_TRACKING_SAFETY_NOTICE, PHASE_DISPLAY } from "@dofek/scoring/menstrual-cycle"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { PageLayout } from "../components/PageLayout.tsx"; @@ -77,6 +77,14 @@ function CyclePage() { {currentPhase.data !== undefined && currentPhase.error ? ( ) : null} +