feat(cycle): redesign the log-day sheet — phase header, field info, wider dialog

Anchor the capture surface to the ring and lower its cognitive load:

- A hue-tinted phase-context strip (day-of-cycle + phase) at the top of the
  sheet, on the wellness-tile surface, honesty-gated the same way the phase-
  education card is — no phase claim until prediction is on, not raw-chart,
  and at least three cycles observed; otherwise a neutral day header.
- Inline field explainers (FieldInfo) on the clinical fields: BBT, cervical
  mucus, ovulation test, spotting, pregnancy/progesterone test, intercourse,
  protection — factual, descriptive-only, reachable by keyboard and screen
  reader.
- BBT is now the shadcn Input rather than a bare element, with a morning-
  timing hint, so it matches every other control.
- The symptothermal section (Temperature & ovulation) auto-expands for
  fertility goals and for an edited day that already holds a fertility sign,
  so a pre-filled value is never hidden behind a collapsed section.
- Desktop dialog widened to lg so the form breathes.

The payload builders and the form-hydration reconciliation are unchanged, so
the wire contract and their unit tests are untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:32:58 +02:00
1 parent eb40f9b46f
commit f3388ceb70
4 files changed
+344 -25

No files matched your search

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { FieldInfo } from "../field-info";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<FieldInfo>", () => {
it("is a real focusable button carrying the accessible label", () => {
const html = render(
<FieldInfo
label="What is basal body temperature?"
detail="Your resting temperature."
/>,
);
// A real <button type="button"> — keyboard- and SR-reachable, not hover-only.
expect(html).toContain('type="button"');
expect(html).toContain('aria-label="What is basal body temperature?"');
// The e2e hook the cycle sheet asserts on.
expect(html).toContain('data-slot="cycle-field-info"');
});
it("starts closed so the explainer opens on tap/focus, not hover-only", () => {
// Radix renders the tooltip content lazily (portal, on open); the trigger
// therefore reports a closed state until interacted with.
const html = render(
<FieldInfo label="Cervical mucus" detail="Discharge changes across the cycle." />,
);
expect(html).toContain('data-state="closed"');
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { PhaseDayHeader } from "../log-day-sheet";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const okGate = {
predictionEnabled: true,
rawChartMode: false,
cyclesObserved: 4,
};
describe("<PhaseDayHeader> (honesty gate)", () => {
it("shows the phase + day when the gate passes", () => {
const html = render(
<PhaseDayHeader phase="OVULATORY" dayOfCycle={14} {...okGate} />,
);
expect(html).toContain('data-slot="cycle-log-phase-header"');
expect(html).toContain('data-phase="OVULATORY"');
// "Day 14 · Ovulatory" — the phase word is only claimed under a passing gate.
expect(html).toContain("Day 14");
expect(html).toContain("Ovulatory");
});
it("makes no phase claim when prediction is off (neutral day header)", () => {
const html = render(
<PhaseDayHeader
phase="OVULATORY"
dayOfCycle={14}
predictionEnabled={false}
rawChartMode={false}
cyclesObserved={4}
/>,
);
expect(html).toContain('data-phase="none"');
// Still anchors to the day count, but never names the phase.
expect(html).toContain("Day 14");
expect(html).not.toContain("Ovulatory");
});
it("makes no phase claim with too few observed cycles", () => {
const html = render(
<PhaseDayHeader
phase="LUTEAL"
dayOfCycle={20}
predictionEnabled
rawChartMode={false}
cyclesObserved={1}
/>,
);
expect(html).toContain('data-phase="none"');
expect(html).not.toContain("Luteal");
});
it("renders nothing when neither a trustworthy phase nor a day count exists", () => {
const html = render(
<PhaseDayHeader
phase={null}
dayOfCycle={null}
predictionEnabled={false}
rawChartMode={false}
cyclesObserved={0}
/>,
);
expect(html).toBe("");
});
});
+6
View File
@@ -403,6 +403,12 @@ export function CycleView() {
date={selectedDate}
today={today}
activePeriod={wheel.phase === "MENSTRUAL"}
phase={wheel.phase}
dayOfCycle={wheel.dayOfCycle}
goal={goal}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
/>
</div>
);
+229 -25
View File
@@ -48,8 +48,10 @@ import {
} from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE } from "./phase-tokens";
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
import type { CyclePhase, CycleGoal } from "./types";
import {
useCreateCustomSymptom,
useCustomSymptoms,
@@ -309,6 +311,11 @@ export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
/** The MIN_CYCLES gate the phase-education card uses — mirrored here so the
* sheet's phase-context header makes the same honesty claim (no phase label
* until prediction is on, not raw-chart, and at least three cycles seen). */
const MIN_CYCLES_FOR_PHASE = 3;
export interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -322,6 +329,86 @@ export interface LogDaySheetProps {
* progress (QA M2).
*/
activePeriod?: boolean;
/**
* The active cycle phase from the calendar read (or null). Drives the
* hue-tinted phase-context header so the sheet visually belongs to the ring.
*/
phase?: CyclePhase | null;
/** Day-of-cycle for the header ("Day 14 · Ovulatory"); null when no cycle. */
dayOfCycle?: number | null;
/**
* The user's cycle goal — fertility goals (TTC / avoid-pregnancy) auto-open
* the symptothermal (Temperature & ovulation) section, mirroring Apple's
* "show the categories that matter to you".
*/
goal?: CycleGoal;
/** Honesty-gate inputs for the phase-context header (mirror the education card). */
predictionEnabled?: boolean;
rawChartMode?: boolean;
cyclesObserved?: number;
}
/**
* v1.18.1 — the hue-tinted phase-context strip atop the sheet, echoing the
* ring ("Day 14 · Ovulatory" + a phase hue dot). It anchors the capture
* surface to the wheel + education card so they read as one family. Honesty
* gate: when the phase label isn't trustworthy (prediction off / raw-chart /
* fewer than three cycles, or no resolved phase) it shows a neutral header
* with no phase claim — never a confident phase word the data can't back.
*/
export function PhaseDayHeader({
phase,
dayOfCycle,
predictionEnabled,
rawChartMode,
cyclesObserved,
}: {
phase: CyclePhase | null;
dayOfCycle: number | null;
predictionEnabled: boolean;
rawChartMode: boolean;
cyclesObserved: number;
}) {
const { t } = useTranslations();
const stillLearning =
!phase ||
!predictionEnabled ||
rawChartMode ||
cyclesObserved < MIN_CYCLES_FOR_PHASE;
// Nothing trustworthy to anchor to (no phase + no day count) → render no
// strip at all rather than an empty tile.
if (stillLearning && dayOfCycle == null) return null;
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
const showPhase = !stillLearning && phase != null;
return (
<div
data-slot="cycle-log-phase-header"
data-phase={showPhase ? phase : "none"}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile flex items-center gap-2.5 rounded-xl px-4 py-3"
>
{showPhase ? (
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
) : null}
<p className="text-foreground text-sm font-medium">
{showPhase && dayOfCycle != null
? t("cycle.sheet.phaseDayHeader", {
day: dayOfCycle,
phase: t(`cycle.phase.${phase}`),
})
: dayOfCycle != null
? t("cycle.ring.dayOfCycle", { day: dayOfCycle })
: t("cycle.phaseEducation.stillLearning")}
</p>
</div>
);
}
function Chip({
@@ -383,6 +470,12 @@ export function LogDaySheet({
onOpenChange,
date,
activePeriod = false,
phase = null,
dayOfCycle = null,
goal,
predictionEnabled = false,
rawChartMode = false,
cyclesObserved = 0,
}: LogDaySheetProps) {
const { t } = useTranslations();
const logDay = useLogDay();
@@ -429,6 +522,12 @@ export function LogDaySheet({
);
const [note, setNote] = useState("");
const [rowId, setRowId] = useState<string | null>(null);
// Goal-aware disclosure for the symptothermal (Temperature & ovulation)
// section. Re-evaluated once per (open, date, fetched-row) in the hydration
// block below: open it when the goal is fertility-oriented (Apple's
// "show the categories that matter to you") OR when an edited day already
// holds one of its signs (so a pre-filled value is never hidden — §3.4).
const [fertilityOpen, setFertilityOpen] = useState(false);
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
// tuple. Tracked as an "adjust state when a prop changes" during render
@@ -459,6 +558,22 @@ export function LogDaySheet({
setProgesteroneTest(dto?.progesteroneTest ?? null);
setContraceptive(dto?.contraceptive ?? null);
setNote(dto?.note ?? "");
// Open the symptothermal section when the goal makes it primary, or when
// the edited day already carries any of its signs (BBT / OPK / mucus /
// cervix) — so a pre-filled fertility value is never hidden on edit.
const fertilityFilled =
dto?.basalBodyTempC != null ||
dto?.ovulationTest != null ||
(showCervix
? dto?.cervixPosition != null ||
dto?.cervixFirmness != null ||
dto?.cervixOpening != null
: dto?.cervicalMucus != null);
setFertilityOpen(
goal === "TRYING_TO_CONCEIVE" ||
goal === "AVOID_PREGNANCY" ||
fertilityFilled,
);
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
@@ -569,6 +684,7 @@ export function LogDaySheet({
onOpenChange={onOpenChange}
title={t("cycle.sheet.title")}
description={t("cycle.sheet.description")}
contentWidth="lg"
footer={
<>
{rowId ? (
@@ -603,6 +719,17 @@ export function LogDaySheet({
</>
}
>
{/* Phase-context strip — echoes the ring (day-of-cycle + phase + hue)
so the capture surface belongs to the wheel. Honesty-gated: no phase
claim until prediction is trustworthy. */}
<PhaseDayHeader
phase={phase}
dayOfCycle={dayOfCycle}
predictionEnabled={predictionEnabled}
rawChartMode={rawChartMode}
cyclesObserved={cyclesObserved}
/>
{/* One-tap period boundaries — available on any selected date so a
forgotten day-1 can be corrected retroactively (M3). "End period"
shows only while a period is actually open (M2). */}
@@ -651,10 +778,19 @@ export function LogDaySheet({
</Chip>
))}
</div>
<div className="mt-2 flex items-center justify-between">
<Label htmlFor="cycle-intermenstrual" className="text-sm font-normal">
{t("cycle.sheet.intermenstrualBleeding")}
</Label>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-intermenstrual"
className="text-sm font-normal"
>
{t("cycle.sheet.intermenstrualBleeding")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.spottingLabel")}
detail={t("cycle.fieldInfo.spotting")}
/>
</div>
<Switch
id="cycle-intermenstrual"
checked={intermenstrual}
@@ -727,16 +863,28 @@ export function LogDaySheet({
</SheetSection>
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
sign (mucus or cervix) grouped under one disclosure. */}
sign (mucus or cervix) grouped under one disclosure. Goal-aware
default: open for fertility goals or when an edited day already
carries a fertility sign. */}
<SheetSection
title={t("cycle.sheet.temperatureSection")}
open={fertilityOpen}
onOpenChange={setFertilityOpen}
summary={
<SheetSectionCount count={temperatureCount(formState, showCervix)} />
}
>
{/* BBT */}
<Field label={t("cycle.sheet.temperature")}>
<input
<Field
label={t("cycle.sheet.temperature")}
info={
<FieldInfo
label={t("cycle.fieldInfo.bbtLabel")}
detail={t("cycle.fieldInfo.bbt")}
/>
}
>
<Input
type="number"
inputMode="decimal"
step="0.01"
@@ -745,9 +893,12 @@ export function LogDaySheet({
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>
<p className="text-muted-foreground text-xs">
{t("cycle.sheet.bbtHint")}
</p>
{resolveBbt(bbt) != null && (
<div className="mt-3 flex items-start justify-between gap-3">
<div className="flex flex-col">
@@ -768,7 +919,15 @@ export function LogDaySheet({
</Field>
{/* Ovulation test */}
<Field label={t("cycle.sheet.ovulationTest")}>
<Field
label={t("cycle.sheet.ovulationTest")}
info={
<FieldInfo
label={t("cycle.fieldInfo.ovulationTestLabel")}
detail={t("cycle.fieldInfo.ovulationTest")}
/>
}
>
<div className="flex flex-wrap gap-2">
{OPK_VALUES.map((v) => (
<Chip
@@ -818,7 +977,15 @@ export function LogDaySheet({
</div>
</Field>
) : (
<Field label={t("cycle.sheet.mucus")}>
<Field
label={t("cycle.sheet.mucus")}
info={
<FieldInfo
label={t("cycle.fieldInfo.mucusLabel")}
detail={t("cycle.fieldInfo.mucus")}
/>
}
>
<div className="flex flex-wrap gap-2">
{MUCUS_VALUES.map((v) => (
<Chip
@@ -842,10 +1009,19 @@ export function LogDaySheet({
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
{t("cycle.sheet.intercourse")}
</Label>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-intercourse"
className="text-sm font-normal"
>
{t("cycle.sheet.intercourse")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.intercourseLabel")}
detail={t("cycle.fieldInfo.intercourse")}
/>
</div>
<Switch
id="cycle-intercourse"
checked={intercourse}
@@ -853,13 +1029,19 @@ export function LogDaySheet({
/>
</div>
{intercourse ? (
<div className="flex items-center justify-between">
<Label
htmlFor="cycle-protected"
className="text-muted-foreground text-sm font-normal"
>
{t("cycle.sheet.protected")}
</Label>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-protected"
className="text-muted-foreground text-sm font-normal"
>
{t("cycle.sheet.protected")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.protectedLabel")}
detail={t("cycle.fieldInfo.protected")}
/>
</div>
<Switch
id="cycle-protected"
checked={protectedSex}
@@ -894,7 +1076,15 @@ export function LogDaySheet({
summary={<SheetSectionCount count={testsCount(formState)} />}
>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<Field
label={t("cycle.sheet.pregnancyTest")}
info={
<FieldInfo
label={t("cycle.fieldInfo.pregnancyTestLabel")}
detail={t("cycle.fieldInfo.pregnancyTest")}
/>
}
>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
@@ -911,7 +1101,15 @@ export function LogDaySheet({
</Field>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<Field
label={t("cycle.sheet.progesteroneTest")}
info={
<FieldInfo
label={t("cycle.fieldInfo.progesteroneTestLabel")}
detail={t("cycle.fieldInfo.progesteroneTest")}
/>
}
>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
@@ -954,14 +1152,20 @@ export function LogDaySheet({
function Field({
label,
info,
children,
}: {
label: string;
/** Optional inline "?" explainer rendered next to the label (FieldInfo). */
info?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<p className="text-sm font-medium">{label}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{label}</p>
{info}
</div>
{children}
</div>
);