mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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("–");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user