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
4 changes: 4 additions & 0 deletions packages/mobile/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<StorybookDateTimePicker
accessibilityLabel="Period start date"
onChange={onChange}
value={new Date(2026, 6, 27, 12)}
/>,
);

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),
);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={selectPreviousDay}
style={styles.picker}
>
<Text style={styles.text}>
{value.toLocaleDateString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</Text>
</Pressable>
);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

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,
},
});
114 changes: 114 additions & 0 deletions packages/mobile/app/cycle.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<AppRouter> {
return () =>
({ op }) =>
createMockObservable(op.path);
}

function createMockObservable(path: string): OperationResultObservable<AppRouter, unknown> {
const result: OperationResultObservable<AppRouter, unknown> = {
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;
},
};
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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 },
},
}),
[],
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
);
const trpcClient = useMemo(() => trpc.createClient({ links: [createMockLink()] }), []);

return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<CycleScreen />
</View>
</QueryClientProvider>
</trpc.Provider>
);
}

const meta = {
title: "Pages/CycleTracking",
component: CycleScreen,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof CycleScreen>;

export default meta;

type Story = StoryObj<typeof meta>;

export const CurrentPhase: Story = {
render: () => <CycleStoryFrame />,
};
17 changes: 17 additions & 0 deletions packages/mobile/app/cycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CycleScreen />);

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 = [
{
Expand Down
31 changes: 29 additions & 2 deletions packages/mobile/app/cycle.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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";
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 {
Expand Down Expand Up @@ -85,6 +85,14 @@ export default function CycleScreen() {
minHeight={72}
/>
) : null}
<View
accessible
accessibilityLabel={`Cycle tracking safety notice. ${CYCLE_TRACKING_SAFETY_NOTICE}`}
style={styles.safetyNotice}
>
<Text style={styles.safetyNoticeTitle}>Tracking limitation</Text>
<Text style={styles.safetyNoticeText}>{CYCLE_TRACKING_SAFETY_NOTICE}</Text>
</View>
</View>

<View style={styles.card}>
Expand Down Expand Up @@ -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,
},
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
emptyText: {
color: colors.textTertiary,
fontSize: 14,
Expand Down
6 changes: 4 additions & 2 deletions packages/scoring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
10 changes: 9 additions & 1 deletion packages/scoring/src/menstrual-cycle.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/scoring/src/menstrual-cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/routes/cycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
10 changes: 9 additions & 1 deletion packages/web/src/routes/cycle.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -77,6 +77,14 @@ function CyclePage() {
{currentPhase.data !== undefined && currentPhase.error ? (
<QueryStatePanel error={currentPhase.error} height={72} />
) : null}
<aside
aria-label="Cycle tracking safety notice"
className="mt-4 rounded-lg border border-border bg-surface-hover p-3"
role="note"
>
<p className="text-sm font-medium text-foreground">Tracking limitation</p>
<p className="mt-1 text-sm text-muted">{CYCLE_TRACKING_SAFETY_NOTICE}</p>
</aside>
</div>

<div className="card p-6">
Expand Down
Loading