merge: cycle log-day sheet redesign — phase header, field tooltips, goal-aware fertility

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:42:29 +02:00
commit a7b2b61b99
11 files changed
+531 -31

No files matched your search

+21 -1
View File
@@ -6346,7 +6346,9 @@
"delete": "Löschen",
"temperatureSection": "Temperatur & Eisprung",
"intimacySection": "Intimität & Verhütung",
"testsSection": "Tests"
"testsSection": "Tests",
"phaseDayHeader": "Tag {day} · {phase}",
"bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen."
},
"settings": {
"title": "Zyklus-Einstellungen",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Gesch. Eisprung",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Bestätigter Eisprung"
},
"fieldInfo": {
"bbt": "Deine Ruhetemperatur, morgens als Erstes vor dem Aufstehen gemessen. Nach dem Eisprung steigt sie leicht an (etwa 0,2–0,3 °C).",
"bbtLabel": "Was ist die Basaltemperatur?",
"mucus": "Der Ausfluss verändert sich im Zyklus – von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.",
"mucusLabel": "Was ist Zervixschleim?",
"ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt – den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.",
"ovulationTestLabel": "Was ist ein Ovulationstest?",
"spotting": "Leichte Blutung außerhalb der Periode.",
"spottingLabel": "Was ist Schmierblutung zwischen den Perioden?",
"pregnancyTest": "Ergebnis eines Schwangerschaftstests (hCG) für zu Hause.",
"pregnancyTestLabel": "Was ist ein Schwangerschaftstest?",
"progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat – das Progesteron steigt danach an.",
"progesteroneTestLabel": "Was ist ein Progesterontest?",
"intercourse": "Ob an diesem Tag Geschlechtsverkehr stattgefunden hat.",
"intercourseLabel": "Was erfasst Geschlechtsverkehr?",
"protected": "Ob beim Verkehr verhütet wurde.",
"protectedLabel": "Was bedeutet geschützt?"
}
},
"cycleReminders": {
+21 -1
View File
@@ -6346,7 +6346,9 @@
"clear": "None",
"temperatureSection": "Temperature & ovulation",
"intimacySection": "Intimacy & contraception",
"testsSection": "Tests"
"testsSection": "Tests",
"phaseDayHeader": "Day {day} · {phase}",
"bbtHint": "Take it first thing in the morning, before getting up."
},
"testResult": {
"POSITIVE": "Positive",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Est. ovulation",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Confirmed ovulation"
},
"fieldInfo": {
"bbt": "Your resting temperature, taken first thing in the morning before getting up. It rises slightly (about 0.2–0.3 °C) after ovulation.",
"bbtLabel": "What is basal body temperature?",
"mucus": "Vaginal discharge changes across the cycle — from dry or sticky to creamy, then watery and egg-white-like around the most fertile days.",
"mucusLabel": "What is cervical mucus?",
"ovulationTest": "A home urine test that detects the LH surge — the hormone spike about 24–36 h before ovulation. A positive flags the most fertile window.",
"ovulationTestLabel": "What is an ovulation test?",
"spotting": "Light bleeding outside the period.",
"spottingLabel": "What is spotting between periods?",
"pregnancyTest": "Result of a home pregnancy (hCG) test.",
"pregnancyTestLabel": "What is a pregnancy test?",
"progesteroneTest": "A test confirming ovulation has occurred — progesterone rises afterward.",
"progesteroneTestLabel": "What is a progesterone test?",
"intercourse": "Whether sexual intercourse took place on this day.",
"intercourseLabel": "What does intercourse track?",
"protected": "Whether contraception was used during intercourse.",
"protectedLabel": "What does protected mean?"
}
},
"cycleReminders": {
+21 -1
View File
@@ -6346,7 +6346,9 @@
"delete": "Eliminar",
"temperatureSection": "Temperatura y ovulación",
"intimacySection": "Intimidad y anticoncepción",
"testsSection": "Pruebas"
"testsSection": "Pruebas",
"phaseDayHeader": "Día {day} · {phase}",
"bbtHint": "Mídela a primera hora de la mañana, antes de levantarte."
},
"settings": {
"title": "Ajustes del ciclo",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Ovulación est.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulación confirmada"
},
"fieldInfo": {
"bbt": "Tu temperatura en reposo, medida a primera hora de la mañana antes de levantarte. Sube ligeramente (unos 0,2–0,3 °C) tras la ovulación.",
"bbtLabel": "¿Qué es la temperatura basal?",
"mucus": "El flujo vaginal cambia a lo largo del ciclo: de seco o pegajoso a cremoso, y luego acuoso y como clara de huevo en los días más fértiles.",
"mucusLabel": "¿Qué es el moco cervical?",
"ovulationTest": "Una prueba de orina casera que detecta el pico de LH, el aumento hormonal unas 24–36 h antes de la ovulación. Un positivo señala la ventana más fértil.",
"ovulationTestLabel": "¿Qué es una prueba de ovulación?",
"spotting": "Sangrado leve fuera de la menstruación.",
"spottingLabel": "¿Qué es el manchado entre periodos?",
"pregnancyTest": "Resultado de una prueba de embarazo casera (hCG).",
"pregnancyTestLabel": "¿Qué es una prueba de embarazo?",
"progesteroneTest": "Una prueba que confirma que ha ocurrido la ovulación: la progesterona sube después.",
"progesteroneTestLabel": "¿Qué es una prueba de progesterona?",
"intercourse": "Si hubo relaciones sexuales este día.",
"intercourseLabel": "¿Qué registra relaciones?",
"protected": "Si se usó anticoncepción durante las relaciones.",
"protectedLabel": "¿Qué significa protegido?"
}
},
"cycleReminders": {
+21 -1
View File
@@ -6346,7 +6346,9 @@
"delete": "Supprimer",
"temperatureSection": "Température et ovulation",
"intimacySection": "Intimité et contraception",
"testsSection": "Tests"
"testsSection": "Tests",
"phaseDayHeader": "Jour {day} · {phase}",
"bbtHint": "Prends-la dès le matin, avant de te lever."
},
"settings": {
"title": "Réglages du cycle",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Ovulation est.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulation confirmée"
},
"fieldInfo": {
"bbt": "Ta température au repos, prise dès le matin avant de te lever. Elle augmente légèrement (environ 0,2–0,3 °C) après l'ovulation.",
"bbtLabel": "Qu'est-ce que la température basale ?",
"mucus": "Les pertes vaginales évoluent au fil du cycle — de sèches ou collantes à crémeuses, puis aqueuses et comme du blanc d'œuf autour des jours les plus fertiles.",
"mucusLabel": "Qu'est-ce que la glaire cervicale ?",
"ovulationTest": "Un test urinaire à domicile qui détecte le pic de LH — la montée hormonale environ 24–36 h avant l'ovulation. Un test positif signale la période la plus fertile.",
"ovulationTestLabel": "Qu'est-ce qu'un test d'ovulation ?",
"spotting": "Léger saignement en dehors des règles.",
"spottingLabel": "Qu'est-ce que le spotting entre les règles ?",
"pregnancyTest": "Résultat d'un test de grossesse (hCG) à domicile.",
"pregnancyTestLabel": "Qu'est-ce qu'un test de grossesse ?",
"progesteroneTest": "Un test confirmant que l'ovulation a eu lieu — la progestérone augmente ensuite.",
"progesteroneTestLabel": "Qu'est-ce qu'un test de progestérone ?",
"intercourse": "Si un rapport sexuel a eu lieu ce jour-là.",
"intercourseLabel": "Que suit le rapport ?",
"protected": "Si une contraception a été utilisée pendant le rapport.",
"protectedLabel": "Que signifie protégé ?"
}
},
"cycleReminders": {
+21 -1
View File
@@ -6346,7 +6346,9 @@
"delete": "Elimina",
"temperatureSection": "Temperatura e ovulazione",
"intimacySection": "Intimità e contraccezione",
"testsSection": "Test"
"testsSection": "Test",
"phaseDayHeader": "Giorno {day} · {phase}",
"bbtHint": "Misurala appena sveglia, prima di alzarti."
},
"settings": {
"title": "Impostazioni del ciclo",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Ovulazione stim.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulazione confermata"
},
"fieldInfo": {
"bbt": "La tua temperatura a riposo, misurata appena sveglia prima di alzarti. Dopo l'ovulazione sale leggermente (circa 0,2–0,3 °C).",
"bbtLabel": "Cos'è la temperatura basale?",
"mucus": "Le perdite vaginali cambiano durante il ciclo — da secche o appiccicose a cremose, poi acquose e simili ad albume nei giorni più fertili.",
"mucusLabel": "Cos'è il muco cervicale?",
"ovulationTest": "Un test delle urine domestico che rileva il picco di LH — l'aumento ormonale circa 24–36 h prima dell'ovulazione. Un positivo segnala la finestra più fertile.",
"ovulationTestLabel": "Cos'è un test di ovulazione?",
"spotting": "Lieve sanguinamento al di fuori delle mestruazioni.",
"spottingLabel": "Cos'è lo spotting tra le mestruazioni?",
"pregnancyTest": "Risultato di un test di gravidanza (hCG) domestico.",
"pregnancyTestLabel": "Cos'è un test di gravidanza?",
"progesteroneTest": "Un test che conferma l'avvenuta ovulazione — il progesterone sale dopo.",
"progesteroneTestLabel": "Cos'è un test del progesterone?",
"intercourse": "Se in questo giorno ci sono stati rapporti sessuali.",
"intercourseLabel": "Cosa registra i rapporti?",
"protected": "Se durante il rapporto è stata usata la contraccezione.",
"protectedLabel": "Cosa significa protetto?"
}
},
"cycleReminders": {
+21 -1
View File
@@ -6346,7 +6346,9 @@
"delete": "Usuń",
"temperatureSection": "Temperatura i owulacja",
"intimacySection": "Intymność i antykoncepcja",
"testsSection": "Testy"
"testsSection": "Testy",
"phaseDayHeader": "Dzień {day} · {phase}",
"bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem."
},
"settings": {
"title": "Ustawienia cyklu",
@@ -6422,6 +6424,24 @@
"ovulationEstimate": "Szac. owulacja",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Potwierdzona owulacja"
},
"fieldInfo": {
"bbt": "Twoja temperatura spoczynkowa, mierzona rano zaraz po przebudzeniu, przed wstaniem. Po owulacji nieznacznie rośnie (o około 0,2–0,3 °C).",
"bbtLabel": "Czym jest temperatura podstawowa?",
"mucus": "Wydzielina z pochwy zmienia się w cyklu — od suchej lub lepkiej do kremowej, a następnie wodnistej i przypominającej białko jaja w najbardziej płodnych dniach.",
"mucusLabel": "Czym jest śluz szyjkowy?",
"ovulationTest": "Domowy test moczu wykrywający skok LH — wyrzut hormonu około 24–36 h przed owulacją. Wynik dodatni wskazuje najbardziej płodne okno.",
"ovulationTestLabel": "Czym jest test owulacyjny?",
"spotting": "Lekkie krwawienie poza okresem.",
"spottingLabel": "Czym jest plamienie między okresami?",
"pregnancyTest": "Wynik domowego testu ciążowego (hCG).",
"pregnancyTestLabel": "Czym jest test ciążowy?",
"progesteroneTest": "Test potwierdzający, że doszło do owulacji — progesteron rośnie po niej.",
"progesteroneTestLabel": "Czym jest test progesteronu?",
"intercourse": "Czy tego dnia doszło do stosunku.",
"intercourseLabel": "Co śledzi stosunek?",
"protected": "Czy podczas stosunku zastosowano antykoncepcję.",
"protectedLabel": "Co oznacza zabezpieczony?"
}
},
"cycleReminders": {
@@ -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>
);
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* v1.18.1 — the cycle log-sheet field-explainer affordance.
*
* A small "?" / info icon that puts a factual, descriptive-only one-liner
* (what a clinical field means — BBT, cervical mucus, an LH surge) right at
* the point of capture, so a first-time user never has to leave the sheet to
* understand a term. Mirrors the mood surface's `MoodExplainerIcon` precedent
* (self-contained `TooltipProvider`, a real focusable `<button>` trigger with
* an `aria-label`, keyboard- and SR-reachable via Radix) but bumps the hit
* area to ≥44 px for touch, since the cycle sheet is a thumb-first surface.
*
* Copy stays descriptive-only — never diagnosis or medical advice — matching
* the phase-education honesty gate.
*/
export function FieldInfo({
label,
detail,
className,
}: {
/** Accessible name for the trigger (what the icon explains). */
label: string;
/** The explanation shown in the tooltip. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
data-slot="cycle-field-info"
className={cn(
// size-6 icon inside an 11-unit (44 px) hit box so touch targets
// clear the WCAG 2.5.8 / Apple HIG minimum without enlarging the
// visible glyph.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring -m-2.5 inline-flex size-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+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>
);