refine(cycle): consolidate disclaimer, align columns, unify tiles

Move the non-medical disclaimer to the cycle-tracking on-ramp and drop it
from the repeating in-feature surfaces (calendar tab, predictions panel);
delete the now-unused disclaimer component. Align the left ring column with
the tab content rather than the tab strip. Raise the settings subheading
sizes to the standard card-description scale. Reflow the reminder rows so a
wrapping label no longer collides with its button at narrow widths. Unify
the wheel and phase-education tile padding. Cite the symptothermal
double-check primary source under the second fertility sign.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:57:16 +02:00
1 parent b8fc648bfc
commit 75129c798d
12 files changed
+100 -125

No files matched your search

@@ -37,33 +37,19 @@ const emptyHistory: CycleHistoryResponse = {
};
describe("<PredictionsPanel>", () => {
it("renders the next-period range and shows the disclaimer under the AVOID_PREGNANCY goal", () => {
it("renders the next-period range without repeating the non-medical disclaimer", () => {
// The disclaimer now lives once at the cycle-tracking on-ramp, never beneath
// the prediction surfaces, so the panel must not render it for any goal.
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="AVOID_PREGNANCY"
/>,
);
expect(html).toContain('data-slot="cycle-disclaimer"');
expect(html).toContain("Estimates only, not medical advice.");
// The range (not a single date) renders.
expect(html).toContain("–");
});
it("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="GENERAL_HEALTH"
/>,
);
expect(html).not.toContain('data-slot="cycle-disclaimer"');
expect(html).not.toContain("Estimates only, not medical advice.");
// The range still renders.
// The range (not a single date) renders.
expect(html).toContain("–");
});
-32
View File
@@ -1,32 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
/**
* v1.15.0 — the fixed non-medical disclaimer for every prediction / fertility
* surface. Mirrors the GLP-1 drug-level-estimate disclaimer pattern
* (`DrugLevelChart`): a muted, border-left note that stays attached wherever
* an estimate renders. The copy is descriptive-not-a-contraceptive,
* not-medical-advice — never a green/safe claim. The text comes from the
* server (`cycle.prediction.disclaimer`, surfaced on the prediction DTO) so
* the web + iOS + the engine speak the same words.
*/
export function CycleDisclaimer({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<p
data-slot="cycle-disclaimer"
className={cn(
"text-foreground/80 border-border bg-muted/40 rounded-md border-l-2 px-3 py-2 text-xs font-medium",
className,
)}
>
{text}
</p>
);
}
+57 -32
View File
@@ -194,7 +194,18 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
))}
</NativeSelect>
<p className="text-muted-foreground text-sm">
{t("cycle.settings.secondarySymptomDescription")}
{t("cycle.settings.secondarySymptomDescription")}{" "}
{/* Primary source for the symptothermal double-check method: the
German NFP prospective cohort (Frank-Herrmann et al., Human
Reproduction 2007). Plain anchor — no markdown renderer. */}
<a
href="https://pubmed.ncbi.nlm.nih.gov/17314078/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("cycle.settings.secondarySymptomCitation")}
</a>
</p>
</div>
@@ -220,21 +231,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
{/* Reminders — the per-channel toggles live on the notifications page;
point users there rather than duplicating the toggle state here. */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.reminders")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.remindersDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.reminders")}
description={t("cycle.settings.remindersDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
{/* Fertile-window reminder — surfaced ONLY under the conception goal.
The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON
@@ -242,21 +243,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
it, so the affordance never appears (and fertile language never
shows) for the other goals (the inclusive-framing rule). */}
{goal === "TRYING_TO_CONCEIVE" ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.fertileReminder")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.fertileReminderDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.fertileReminder")}
description={t("cycle.settings.fertileReminderDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
) : null}
{/* Cycle data export + delete live with the main Settings → Export and
@@ -306,7 +297,7 @@ function ToggleRow({
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<p className="text-muted-foreground text-xs">{description}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch
id={id}
@@ -318,6 +309,40 @@ function ToggleRow({
);
}
/**
* A "label + description → go to notifications" row. The long fertile-window
* label collided with its button when both shared one tight flex line; here the
* row stacks on the narrowest screens (button on its own line, full-width tap
* target) and reflows to a clean inline layout from `sm` up, where the text
* column can wrap freely while the button stays top-aligned and never shrinks.
*/
function LinkRow({
title,
description,
linkLabel,
}: {
title: string;
description: string;
linkLabel: string;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Button
variant="outline"
size="sm"
asChild
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
<Link href="/notifications">{linkLabel}</Link>
</Button>
</div>
);
}
function PriorField({
id,
label,
+8 -20
View File
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
@@ -118,22 +117,11 @@ export function CycleView() {
const loading = calendar.isLoading && !calendar.data;
const calendarError = calendar.isError && !calendar.data;
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
// AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger
// "not a contraceptive method" caveat. Prefer the server-resolved
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
// key for the no-prediction calendar tab (QA H-1).
const goal = calendar.data?.profile.goal;
// Honesty-gate inputs for the phase-education card (Clue precedent): the
// predictive phase framing only shows when prediction is on, not in
// raw-chart mode, and at least three cycles are observed.
const calProfile = calendar.data?.profile;
const disclaimerText =
calendar.data?.prediction?.disclaimer ??
t(
goal === "AVOID_PREGNANCY"
? "cycle.disclaimer"
: "cycle.prediction.disclaimer",
);
return (
<div className="space-y-6">
@@ -162,8 +150,13 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* Wheel + compact phase card — grouped so they stick together. The
`lg:mt-15` (3.75rem) drops the column so the wheel tile's top edge
lines up with the calendar / predictions content rather than the
tab strip: TabsList `h-9` (2.25rem) + the Tabs root `gap-2` (0.5rem)
+ the content's `mt-4` (1rem). Below `lg` the column stacks first and
the offset collapses to zero. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6 lg:mt-15">
{/* Wheel — the signature ring on the premium wellness-tile surface.
`data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise`
— the keyframe selector is `[data-revealed="true"]
@@ -180,7 +173,7 @@ export function CycleView() {
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5",
play && "wellness-tile-rise",
)}
>
@@ -321,9 +314,6 @@ export function CycleView() {
)}
</CardContent>
</Card>
{!loading && !calendarError ? (
<CycleDisclaimer text={disclaimerText} />
) : null}
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
@@ -339,8 +329,6 @@ export function CycleView() {
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
goal={goal}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
+3 -22
View File
@@ -11,7 +11,6 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
@@ -21,8 +20,9 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types";
* Shows the next-period RANGE (a window, with a confidence pill), the fertile
* window (already goal-gated server-side — the API nulls it unless TTC, so we
* just render what is present), the "still learning" state for < 3 cycles or
* raw-chart mode, the cycle-history stats, and the fixed non-medical
* disclaimer. Never a single dated next-period claim — always the range.
* raw-chart mode, and the cycle-history stats. Never a single dated next-period
* claim — always the range. The non-medical disclaimer is set once at the
* cycle-tracking on-ramp, not repeated beneath every prediction surface.
*/
function formatDate(d: string): string {
@@ -51,32 +51,15 @@ export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** Fallback disclaimer when no prediction carries one. */
fallbackDisclaimer?: string;
/** The active cycle goal — only AVOID_PREGNANCY surfaces the (deliberate)
* contraceptive-safety caveat here; every other goal drops the generic
* "predictions are estimates" line so it does not repeat under every graph. */
goal?: string;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
fallbackDisclaimer,
goal,
}: PredictionsPanelProps) {
const { t } = useTranslations();
// Only the AVOID_PREGNANCY goal shows a disclaimer here: it surfaces the
// fertile window, so the stronger "not a contraceptive method" caveat is a
// deliberate medical safety note. For all other goals the generic
// estimate-caveat is suppressed (it already lives once with the data).
const disclaimer =
goal === "AVOID_PREGNANCY"
? (prediction?.disclaimer ?? fallbackDisclaimer ?? "")
: "";
return (
<div className="space-y-4">
<Card>
@@ -129,8 +112,6 @@ export function PredictionsPanel({
) : null}
</>
)}
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
</CardContent>
</Card>
@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { CalendarHeart } from "lucide-react";
import { CalendarHeart, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -79,6 +79,21 @@ export function CycleTrackingCard({
className="mt-0.5 shrink-0"
/>
</div>
{/* The single, prominent non-medical disclaimer for the whole cycle
vertical. It lives here at the on-ramp — where expectations are
set — instead of repeating beneath every prediction surface. */}
<div
data-slot="cycle-tracking-disclaimer"
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
>
<Info
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-foreground/80 text-xs leading-relaxed">
{t("settings.cycleTracking.disclaimer")}
</p>
</div>
{enabled ? (
<Button
variant="outline"