diff --git a/messages/de.json b/messages/de.json index 86bdf4591..4b0b74011 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Eiweißartig", "label": "Zervixschleim" }, + "cervixPosition": { + "LOW": "Tief", + "HIGH": "Hoch" + }, + "cervixFirmness": { + "FIRM": "Fest", + "SOFT": "Weich" + }, + "cervixOpening": { + "CLOSED": "Geschlossen", + "OPEN": "Geöffnet" + }, "goal": { "GENERAL_HEALTH": "Allgemeine Gesundheit", "AVOID_PREGNANCY": "Verhütung", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", "mucus": "Zervixschleim", + "cervix": "Gebärmutterhals", + "cervixPosition": "Position", + "cervixFirmness": "Festigkeit", + "cervixOpening": "Öffnung", "intercourse": "Geschlechtsverkehr", "protected": "Geschützt", "note": "Notiz", @@ -5915,6 +5931,12 @@ "priorsDescription": "Diese Vorgaben helfen deinen Prognosen, bevor du viele Zyklen erfasst hast. Leer lassen, um die Standardwerte zu nutzen.", "rawChartMode": "Roh-Diagramm-Modus", "rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.", + "secondarySymptomLabel": "Zweites Fruchtbarkeitszeichen", + "secondarySymptomDescription": "Das zweite Zeichen, das für die symptothermale Doppelkontrolle mit deiner Temperatur kombiniert wird. Die meisten beobachten den Zervixschleim; wechsle zur Gebärmutterhals-Beobachtung, wenn du das bevorzugst.", + "secondarySymptom": { + "MUCUS": "Zervixschleim", + "CERVIX": "Gebärmutterhals" + }, "prediction": "Prognosen", "predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.", "encryption": "Sensible Einträge verschlüsseln", diff --git a/messages/en.json b/messages/en.json index 3ee745a08..ac02d4119 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Egg white", "label": "Cervical mucus" }, + "cervixPosition": { + "LOW": "Low", + "HIGH": "High" + }, + "cervixFirmness": { + "FIRM": "Firm", + "SOFT": "Soft" + }, + "cervixOpening": { + "CLOSED": "Closed", + "OPEN": "Open" + }, "goal": { "GENERAL_HEALTH": "General health", "AVOID_PREGNANCY": "Avoiding pregnancy", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", "mucus": "Cervical mucus", + "cervix": "Cervix", + "cervixPosition": "Position", + "cervixFirmness": "Firmness", + "cervixOpening": "Opening", "intercourse": "Intercourse", "protected": "Protected", "intermenstrualBleeding": "Spotting between periods", @@ -5920,6 +5936,12 @@ "priorsDescription": "These priors help your predictions before you have logged many cycles. Leave blank to use the defaults.", "rawChartMode": "Raw chart mode", "rawChartModeDescription": "Show only your logged data with no predictions or interpretation.", + "secondarySymptomLabel": "Secondary fertility sign", + "secondarySymptomDescription": "The second sign paired with your temperature for the symptothermal double-check. Most people track cervical mucus; switch to cervix observation if you prefer.", + "secondarySymptom": { + "MUCUS": "Cervical mucus", + "CERVIX": "Cervix observation" + }, "prediction": "Predictions", "predictionDescription": "Forecast your next period and, when relevant, your fertile window.", "encryption": "Encrypt sensitive entries", diff --git a/messages/es.json b/messages/es.json index 6ff2de793..76da38cee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Clara de huevo", "label": "Moco cervical" }, + "cervixPosition": { + "LOW": "Bajo", + "HIGH": "Alto" + }, + "cervixFirmness": { + "FIRM": "Firme", + "SOFT": "Blando" + }, + "cervixOpening": { + "CLOSED": "Cerrado", + "OPEN": "Abierto" + }, "goal": { "GENERAL_HEALTH": "Salud general", "AVOID_PREGNANCY": "Evitar el embarazo", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.", "ovulationTest": "Test de ovulación", "mucus": "Moco cervical", + "cervix": "Cuello uterino", + "cervixPosition": "Posición", + "cervixFirmness": "Firmeza", + "cervixOpening": "Apertura", "intercourse": "Relaciones", "protected": "Protegidas", "note": "Nota", @@ -5915,6 +5931,12 @@ "priorsDescription": "Estos valores ayudan a tus predicciones antes de registrar muchos ciclos. Déjalos en blanco para usar los predeterminados.", "rawChartMode": "Modo de gráfico en bruto", "rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.", + "secondarySymptomLabel": "Segundo signo de fertilidad", + "secondarySymptomDescription": "El segundo signo que se combina con tu temperatura para la doble comprobación sintotérmica. La mayoría observa el moco cervical; cambia a la observación del cuello uterino si lo prefieres.", + "secondarySymptom": { + "MUCUS": "Moco cervical", + "CERVIX": "Cuello uterino" + }, "prediction": "Predicciones", "predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.", "encryption": "Cifrar entradas sensibles", diff --git a/messages/fr.json b/messages/fr.json index 1ac723285..fa3dabdd2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Blanc d'œuf", "label": "Glaire cervicale" }, + "cervixPosition": { + "LOW": "Bas", + "HIGH": "Haut" + }, + "cervixFirmness": { + "FIRM": "Ferme", + "SOFT": "Souple" + }, + "cervixOpening": { + "CLOSED": "Fermé", + "OPEN": "Ouvert" + }, "goal": { "GENERAL_HEALTH": "Santé générale", "AVOID_PREGNANCY": "Éviter une grossesse", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", "mucus": "Glaire cervicale", + "cervix": "Col de l'utérus", + "cervixPosition": "Position", + "cervixFirmness": "Fermeté", + "cervixOpening": "Ouverture", "intercourse": "Rapports", "protected": "Protégés", "note": "Note", @@ -5915,6 +5931,12 @@ "priorsDescription": "Ces valeurs aident tes prévisions avant d'avoir enregistré beaucoup de cycles. Laisse vide pour utiliser les valeurs par défaut.", "rawChartMode": "Mode graphique brut", "rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.", + "secondarySymptomLabel": "Deuxième signe de fertilité", + "secondarySymptomDescription": "Le deuxième signe associé à ta température pour la double vérification sympto-thermique. La plupart des personnes observent la glaire cervicale ; passe à l'observation du col de l'utérus si tu préfères.", + "secondarySymptom": { + "MUCUS": "Glaire cervicale", + "CERVIX": "Col de l'utérus" + }, "prediction": "Prévisions", "predictionDescription": "Prévois tes prochaines règles et, le cas échéant, ta fenêtre fertile.", "encryption": "Chiffrer les entrées sensibles", diff --git a/messages/it.json b/messages/it.json index b9f2be8b7..58232fc1b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Albume", "label": "Muco cervicale" }, + "cervixPosition": { + "LOW": "Basso", + "HIGH": "Alto" + }, + "cervixFirmness": { + "FIRM": "Duro", + "SOFT": "Morbido" + }, + "cervixOpening": { + "CLOSED": "Chiuso", + "OPEN": "Aperto" + }, "goal": { "GENERAL_HEALTH": "Salute generale", "AVOID_PREGNANCY": "Evitare la gravidanza", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.", "ovulationTest": "Test di ovulazione", "mucus": "Muco cervicale", + "cervix": "Cervice", + "cervixPosition": "Posizione", + "cervixFirmness": "Consistenza", + "cervixOpening": "Apertura", "intercourse": "Rapporti", "protected": "Protetti", "note": "Nota", @@ -5915,6 +5931,12 @@ "priorsDescription": "Questi valori aiutano le previsioni prima di aver registrato molti cicli. Lascia vuoto per usare i valori predefiniti.", "rawChartMode": "Modalità grafico grezzo", "rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.", + "secondarySymptomLabel": "Secondo segno di fertilità", + "secondarySymptomDescription": "Il secondo segno abbinato alla tua temperatura per il doppio controllo sintotermico. La maggior parte osserva il muco cervicale; passa all'osservazione della cervice se preferisci.", + "secondarySymptom": { + "MUCUS": "Muco cervicale", + "CERVIX": "Cervice" + }, "prediction": "Previsioni", "predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.", "encryption": "Cifra le voci sensibili", diff --git a/messages/pl.json b/messages/pl.json index e60e33682..00c80f0b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Jak białko jaja", "label": "Śluz szyjkowy" }, + "cervixPosition": { + "LOW": "Nisko", + "HIGH": "Wysoko" + }, + "cervixFirmness": { + "FIRM": "Twarda", + "SOFT": "Miękka" + }, + "cervixOpening": { + "CLOSED": "Zamknięta", + "OPEN": "Otwarta" + }, "goal": { "GENERAL_HEALTH": "Ogólne zdrowie", "AVOID_PREGNANCY": "Unikanie ciąży", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", "mucus": "Śluz szyjkowy", + "cervix": "Szyjka macicy", + "cervixPosition": "Położenie", + "cervixFirmness": "Twardość", + "cervixOpening": "Rozwarcie", "intercourse": "Współżycie", "protected": "Zabezpieczone", "note": "Notatka", @@ -5915,6 +5931,12 @@ "priorsDescription": "Te wartości pomagają prognozom, zanim zapiszesz wiele cykli. Zostaw puste, aby użyć wartości domyślnych.", "rawChartMode": "Tryb surowego wykresu", "rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.", + "secondarySymptomLabel": "Drugi objaw płodności", + "secondarySymptomDescription": "Drugi objaw łączony z temperaturą w podwójnej kontroli objawowo-termicznej. Większość obserwuje śluz szyjkowy; przełącz na obserwację szyjki macicy, jeśli wolisz.", + "secondarySymptom": { + "MUCUS": "Śluz szyjkowy", + "CERVIX": "Szyjka macicy" + }, "prediction": "Prognozy", "predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.", "encryption": "Szyfruj wrażliwe wpisy", diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index 00e6a5933..8c9671e01 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -14,6 +14,9 @@ function blank(): DayLogFormState { bbtDisturbed: false, opk: null, mucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, intercourse: false, protectedSex: false, pregnancyTest: null, @@ -103,6 +106,24 @@ describe("buildDayLogInput (new row → omit empties)", () => { expect(patch.temperatureExcluded).toBe(true); }); + it("carries the cervix signs through both payload builders", () => { + const state = { + ...blank(), + cervixPosition: "HIGH" as const, + cervixFirmness: "SOFT" as const, + cervixOpening: "OPEN" as const, + }; + const input = buildDayLogInput(state, "2026-06-06"); + expect(input.cervixPosition).toBe("HIGH"); + expect(input.cervixFirmness).toBe("SOFT"); + expect(input.cervixOpening).toBe("OPEN"); + // A blank edit nulls every cervix sign (clear semantics). + const patch = buildDayLogPatch(blank()); + expect(patch.cervixPosition).toBeNull(); + expect(patch.cervixFirmness).toBeNull(); + expect(patch.cervixOpening).toBeNull(); + }); + it("never marks an absent BBT reading disturbed", () => { // A disturbed flag with no temperature is meaningless — it must reset. const patch = buildDayLogPatch({ ...blank(), bbt: "", bbtDisturbed: true }); diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index aa273765d..68b323cca 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -17,7 +17,11 @@ import { Switch } from "@/components/ui/switch"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { useTranslations } from "@/lib/i18n/context"; -import type { CycleGoal, CycleProfileDTO } from "./types"; +import type { + CycleGoal, + CycleProfileDTO, + SecondarySymptom, +} from "./types"; import { useUpdateCyclePrefs } from "./use-cycle"; /** @@ -43,6 +47,8 @@ const GOALS: CycleGoal[] = [ "OFF", ]; +const SECONDARY_SYMPTOMS: SecondarySymptom[] = ["MUCUS", "CERVIX"]; + function numOrEmpty(v: number | null): string { return v == null ? "" : String(v); } @@ -60,6 +66,9 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { profile.sensitiveCategoryEncryption, ); const [discreet, setDiscreet] = useState(profile.discreetNotifications); + const [secondarySymptom, setSecondarySymptom] = useState( + profile.secondarySymptom, + ); const [cycleLen, setCycleLen] = useState( numOrEmpty(profile.typicalCycleLength), ); @@ -78,6 +87,7 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { async function handleSave() { await update.mutateAsync({ goal, + secondarySymptom, rawChartMode, predictionEnabled, sensitiveCategoryEncryption: encryption, @@ -167,6 +177,33 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + {/* Advanced — symptothermal secondary symptom. The default is mucus; + the choice never forces itself on a casual user (progressive + disclosure: it sits here in the advanced cycle settings). */} +
+ + + setSecondarySymptom(e.target.value as SecondarySymptom) + } + > + {SECONDARY_SYMPTOMS.map((s) => ( + + ))} + +

+ {t("cycle.settings.secondarySymptomDescription")} +

+
+ + + {/* Privacy toggles */} ({ + label, + values, + current, + labelFor, + onToggle, +}: { + label: string; + values: readonly T[]; + current: T | null; + labelFor: (v: T) => string; + onToggle: (v: T) => void; +}) { + return ( +
+

{label}

+
+ {values.map((v) => ( + onToggle(v)}> + {labelFor(v)} + + ))} +
+
+ ); +} + export function LogDaySheet({ open, onOpenChange, @@ -291,6 +335,11 @@ export function LogDaySheet({ const dayLog = useCycleDayLog(open ? date : null); // The caller's custom symptoms, merged into the seeded chip grid. const customSymptoms = useCustomSymptoms(); + // Drives which symptothermal secondary sign the sheet offers — mucus (default) + // or the cervix pickers (progressive disclosure: cervix entry only appears + // when the user chose CERVIX in the advanced cycle settings). + const cycleProfile = useCycleProfile(); + const showCervix = cycleProfile.data?.secondarySymptom === "CERVIX"; const [flow, setFlow] = useState(null); const [intermenstrual, setIntermenstrual] = useState(false); @@ -301,6 +350,13 @@ export function LogDaySheet({ const [bbtDisturbed, setBbtDisturbed] = useState(false); const [opk, setOpk] = useState(null); const [mucus, setMucus] = useState(null); + const [cervixPosition, setCervixPosition] = useState( + null, + ); + const [cervixFirmness, setCervixFirmness] = useState( + null, + ); + const [cervixOpening, setCervixOpening] = useState(null); const [intercourse, setIntercourse] = useState(false); const [protectedSex, setProtectedSex] = useState(false); const [pregnancyTest, setPregnancyTest] = useState( @@ -334,6 +390,9 @@ export function LogDaySheet({ setBbtDisturbed(dto?.temperatureExcluded ?? false); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); + setCervixPosition((dto?.cervixPosition ?? null) as CervixPosition | null); + setCervixFirmness((dto?.cervixFirmness ?? null) as CervixFirmness | null); + setCervixOpening((dto?.cervixOpening ?? null) as CervixOpening | null); setIntercourse(dto?.sexualActivity ?? false); setProtectedSex(dto?.protectedSex ?? false); setPregnancyTest(dto?.pregnancyTest ?? null); @@ -370,6 +429,9 @@ export function LogDaySheet({ bbtDisturbed, opk, mucus, + cervixPosition, + cervixFirmness, + cervixOpening, intercourse, protectedSex, pregnancyTest, @@ -644,20 +706,56 @@ export function LogDaySheet({ - {/* Cervical mucus */} - -
- {MUCUS_VALUES.map((v) => ( - setMucus((cur) => (cur === v ? null : v))} - > - {t(`cycle.mucus.${v}`)} - - ))} -
-
+ {/* Symptothermal secondary symptom: cervical mucus (default) OR the + cervix observation, per the user's advanced-settings choice. Only one + of the two is offered so the log sheet never overwhelms. */} + {showCervix ? ( + +
+ t(`cycle.cervixPosition.${v}`)} + onToggle={(v) => + setCervixPosition((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixFirmness.${v}`)} + onToggle={(v) => + setCervixFirmness((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixOpening.${v}`)} + onToggle={(v) => + setCervixOpening((cur) => (cur === v ? null : v)) + } + /> +
+
+ ) : ( + +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ )} {/* Intercourse + protection */} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 064495b48..bbc799610 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -101,9 +101,18 @@ export interface CycleHistoryResponse { }; } +/** Symptothermal secondary symptom — mucus (default) or cervix observation. */ +export type SecondarySymptom = "MUCUS" | "CERVIX"; + +/** The three Sensiplan cervix signs. */ +export type CervixPosition = "LOW" | "HIGH"; +export type CervixFirmness = "FIRM" | "SOFT"; +export type CervixOpening = "CLOSED" | "OPEN"; + export interface CycleProfileDTO { goal: CycleGoal; cycleTrackingEnabled: boolean; + secondarySymptom: SecondarySymptom; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; @@ -141,6 +150,9 @@ export interface CycleDayLogInput { temperatureExcluded?: boolean; ovulationTest?: OvulationTest; cervicalMucus?: CervicalMucus; + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; sexualActivity?: boolean; protectedSex?: boolean | null; pregnancyTest?: HomeTestResult; @@ -163,6 +175,9 @@ export interface CycleDayLogDTO { temperatureExcluded: boolean; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; + cervixPosition: CervixPosition | null; + cervixFirmness: CervixFirmness | null; + cervixOpening: CervixOpening | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: HomeTestResult | null; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 1f7fca8ee..d6f1ef287 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -29,6 +29,9 @@ import { import type { CalendarResponse, CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, ContraceptiveKind, CycleDayLogDTO, CycleDayLogInput, @@ -240,6 +243,9 @@ export interface CycleDayLogPatch { temperatureExcluded?: boolean; ovulationTest?: OvulationTest | null; cervicalMucus?: CervicalMucus | null; + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; sexualActivity?: boolean; protectedSex?: boolean | null; pregnancyTest?: HomeTestResult | null; @@ -317,6 +323,7 @@ export function useDeleteDayLog() { export interface CyclePrefsPatch { enabled?: boolean; goal?: CycleProfileDTO["goal"]; + secondarySymptom?: CycleProfileDTO["secondarySymptom"]; rawChartMode?: boolean; predictionEnabled?: boolean; discreetNotifications?: boolean;