From 7333d8e40fb916c5d1caf9386db370713c20f8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 10:02:47 +0200 Subject: [PATCH] feat(cycle): add the cycle-tracking web vertical Build the web UI for the cycle-tracking category against the existing `/api/cycle/*` routes: a month calendar + circular phase wheel, a fast log-day sheet, a predictions panel, phase-insight cards, and a single-page settings form, all under a gated `/cycle` tab. - Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the wellness-ring aesthetic with calm per-phase hues) showing the current phase + day-of-cycle, alongside a month grid where logged period days show a filled pip, the predicted next period renders as a ranged band (never a single date), the fertile window shows only when the goal allows it (the API suppresses it server-side otherwise), and ovulation + per-day symptoms carry markers. Reads `GET /api/cycle/calendar`. - Log-day sheet: one-tap period start, flow / symptom / ovulation-test / mucus chips, basal-temperature, intercourse + protection, and a free-text note. Optimistic write to the day-log + period routes; invalidates the cycle query keys. - Predictions panel: next-period range with a confidence pill, goal-gated fertile window, a still-learning state for fewer than three cycles or raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer (reusing the drug-level-estimate disclaimer pattern). - Insights cards render whatever phase-correlation cards the API surfaces, with a graceful empty-state until the feed lands. - Settings: a single dynamic form where the inclusive goal selector drives the copy (conception framing only for trying-to-conceive), plus typical- length priors, raw-chart mode, sensitive-category encryption, discreet notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`. - Nav: a Cycle entry in the sidebar and the mobile More hub, gated on `cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise. Add the cycle query keys + dependent-key bundle to the factory, full copy across all six locales, and dedicated per-theme phase hues. Plain second-person voice, WCAG 2.1 AA, no new dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 159 +++++++- messages/en.json | 159 +++++++- messages/es.json | 159 +++++++- messages/fr.json | 159 +++++++- messages/it.json | 159 +++++++- messages/pl.json | 159 +++++++- src/app/cycle/page.tsx | 44 +++ src/app/globals.css | 22 ++ .../cycle/__tests__/cycle-calendar.test.tsx | 73 ++++ .../cycle/__tests__/cycle-insights.test.tsx | 43 +++ .../cycle/__tests__/cycle-ring.test.tsx | 56 +++ .../__tests__/predictions-panel.test.tsx | 103 +++++ .../cycle/__tests__/wheel-state.test.ts | 58 +++ src/components/cycle/cycle-calendar.tsx | 260 +++++++++++++ src/components/cycle/cycle-disclaimer.tsx | 32 ++ src/components/cycle/cycle-insights.tsx | 86 +++++ src/components/cycle/cycle-ring.tsx | 198 ++++++++++ src/components/cycle/cycle-settings.tsx | 294 ++++++++++++++ src/components/cycle/cycle-view.tsx | 182 +++++++++ src/components/cycle/log-day-sheet.tsx | 361 ++++++++++++++++++ src/components/cycle/phase-tokens.ts | 24 ++ src/components/cycle/predictions-panel.tsx | 242 ++++++++++++ src/components/cycle/symptom-catalog.ts | 114 ++++++ src/components/cycle/types.ts | 134 +++++++ src/components/cycle/use-cycle.ts | 134 +++++++ src/components/cycle/wheel-state.ts | 91 +++++ .../layout/__tests__/bottom-nav.test.tsx | 33 ++ .../layout/__tests__/sidebar-nav.test.tsx | 18 +- src/components/layout/bottom-nav.tsx | 21 +- src/components/layout/sidebar-nav.tsx | 17 +- .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 10 + .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/query-keys.ts | 28 ++ 35 files changed, 3613 insertions(+), 22 deletions(-) create mode 100644 src/app/cycle/page.tsx create mode 100644 src/components/cycle/__tests__/cycle-calendar.test.tsx create mode 100644 src/components/cycle/__tests__/cycle-insights.test.tsx create mode 100644 src/components/cycle/__tests__/cycle-ring.test.tsx create mode 100644 src/components/cycle/__tests__/predictions-panel.test.tsx create mode 100644 src/components/cycle/__tests__/wheel-state.test.ts create mode 100644 src/components/cycle/cycle-calendar.tsx create mode 100644 src/components/cycle/cycle-disclaimer.tsx create mode 100644 src/components/cycle/cycle-insights.tsx create mode 100644 src/components/cycle/cycle-ring.tsx create mode 100644 src/components/cycle/cycle-settings.tsx create mode 100644 src/components/cycle/cycle-view.tsx create mode 100644 src/components/cycle/log-day-sheet.tsx create mode 100644 src/components/cycle/phase-tokens.ts create mode 100644 src/components/cycle/predictions-panel.tsx create mode 100644 src/components/cycle/symptom-catalog.ts create mode 100644 src/components/cycle/types.ts create mode 100644 src/components/cycle/use-cycle.ts create mode 100644 src/components/cycle/wheel-state.ts diff --git a/messages/de.json b/messages/de.json index bdafdb4d7..7474bcc21 100644 --- a/messages/de.json +++ b/messages/de.json @@ -216,7 +216,8 @@ "medicationDescription": "Eine Dosis als genommen markieren", "mood": "Stimmung", "moodDescription": "Stimmung und Tags erfassen" - } + }, + "cycle": "Zyklus" }, "auth": { "login": "Anmelden", @@ -4926,9 +4927,161 @@ "confidenceLow": "Geringe Sicherheit", "confidenceMedium": "Mittlere Sicherheit", "confidenceHigh": "Hohe Sicherheit" - } + }, + "title": "Zyklus-Erkenntnisse", + "empty": "Phasen-Erkenntnisse erscheinen hier, sobald du genug Zyklen erfasst hast. Sie vergleichen deine Werte über die Zyklusphasen hinweg, mit offen gezeigter Statistik.", + "sampleSize": "n = {n}", + "effectSize": "Effektstärke {value}", + "qValue": "q = {value}" }, - "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson." + "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", + "title": "Dein Zyklus", + "subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.", + "tabs": { + "calendar": "Kalender", + "predictions": "Prognosen", + "insights": "Erkenntnisse", + "settings": "Einstellungen" + }, + "logToday": "Heute erfassen", + "startedPeriod": "Periode hat heute begonnen", + "endedPeriod": "Periode heute beendet", + "phase": { + "MENSTRUAL": "Menstruation", + "FOLLICULAR": "Follikelphase", + "OVULATORY": "Ovulation", + "LUTEAL": "Lutealphase", + "none": "Keine Phase", + "label": "Phase" + }, + "flow": { + "NONE": "Keine", + "SPOTTING": "Schmierblutung", + "LIGHT": "Leicht", + "MEDIUM": "Mittel", + "HEAVY": "Stark", + "label": "Stärke" + }, + "ovulationTest": { + "NEGATIVE": "Negativ", + "POSITIVE_LH_SURGE": "LH-Anstieg", + "ESTROGEN_SURGE": "Östrogen-Anstieg", + "INDETERMINATE": "Unklar", + "label": "Ovulationstest" + }, + "mucus": { + "DRY": "Trocken", + "STICKY": "Klebrig", + "CREAMY": "Cremig", + "WATERY": "Wässrig", + "EGG_WHITE": "Eiweißartig", + "label": "Zervixschleim" + }, + "goal": { + "GENERAL_HEALTH": "Allgemeine Gesundheit", + "AVOID_PREGNANCY": "Verhütung", + "TRYING_TO_CONCEIVE": "Kinderwunsch", + "PERIMENOPAUSE": "Perimenopause", + "OFF": "Tracking aus", + "label": "Wofür trackst du?", + "description": "Das bestimmt die Sprache und welche Prognosen du siehst. Hinweise zum fruchtbaren Fenster erscheinen nur bei Kinderwunsch." + }, + "ring": { + "dayOfCycle": "Tag {day}", + "dayUnknown": "—", + "ariaPhase": "Zyklustag {day}, {phase}-Phase", + "ariaUnknown": "Noch kein aktiver Zyklus", + "caption": "deines Zyklus" + }, + "calendar": { + "legendPeriod": "Erfasste Periode", + "legendPredicted": "Prognostizierte Periode", + "legendFertile": "Fruchtbares Fenster", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptome erfasst", + "prevMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "loading": "Kalender wird geladen…", + "empty": "Erfasse Daten, damit sich dein Kalender füllt.", + "today": "Heute", + "dayAria": "{date}" + }, + "predictions": { + "title": "Was als Nächstes kommt", + "nextPeriod": "Nächste Periode", + "nextPeriodRange": "Etwa am {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fruchtbares Fenster", + "ovulation": "Geschätzter Eisprung", + "confidence": "Sicherheit", + "confidenceHigh": "Hoch", + "confidenceMedium": "Mittel", + "confidenceLow": "Niedrig", + "stillLearning": "Noch am Lernen", + "stillLearningBody": "Erfasse mindestens drei vollständige Zyklen, dann werden deine Prognosen genauer. Bis dahin ist dies eine grobe Schätzung.", + "rawMode": "Prognose ist aus", + "rawModeBody": "Du hast Prognosen ausgeschaltet — es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.", + "none": "Noch keine Prognose. Erfasse eine Periode, um zu starten." + }, + "history": { + "title": "Deine Zyklen", + "avgLength": "Durchschnittliche Länge", + "variability": "Schwankung", + "avgPeriodLength": "Durchschnittliche Periode", + "regularity": "Regelmäßigkeit", + "regularityREGULAR": "Regelmäßig", + "regularityIRREGULAR": "Unregelmäßig", + "regularityLEARNING": "Lernend", + "days": "{count} Tage", + "daysPlusMinus": "± {count} Tage", + "none": "Noch keine Zyklen erfasst." + }, + "sheet": { + "title": "Tag erfassen", + "description": "Erfasse Stärke, Symptome und Notizen von heute.", + "date": "Datum", + "flow": "Stärke", + "symptoms": "Symptome", + "temperature": "Basaltemperatur", + "temperaturePlaceholder": "°C", + "ovulationTest": "Ovulationstest", + "mucus": "Zervixschleim", + "intercourse": "Geschlechtsverkehr", + "protected": "Geschützt", + "note": "Notiz", + "notePlaceholder": "Was du dir über heute merken möchtest…", + "save": "Speichern", + "saving": "Speichern…", + "cancel": "Abbrechen", + "saved": "Tag gespeichert", + "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "periodStarted": "Periodenbeginn erfasst", + "clear": "Keine" + }, + "settings": { + "title": "Zyklus-Einstellungen", + "enable": "Zyklus-Tracking", + "enableDescription": "Schalte den Zyklus-Tab für dein Konto ein oder aus.", + "typicalCycleLength": "Typische Zykluslänge (Tage)", + "typicalPeriodLength": "Typische Periodenlänge (Tage)", + "lutealPhaseLength": "Länge der Lutealphase (Tage)", + "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.", + "prediction": "Prognosen", + "predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.", + "encryption": "Sensible Einträge verschlüsseln", + "encryptionDescription": "Verschlüssele absichtsverratende Felder (Schwangerschaftstests, Verhütung, Geschlechtsverkehr) ruhend, zusätzlich zu den stets verschlüsselten Notizen.", + "discreet": "Diskrete Benachrichtigungen", + "discreetDescription": "Verbirg Zyklusdetails im Benachrichtigungstext auf deinem Sperrbildschirm.", + "dataTitle": "Deine Daten", + "dataDescription": "Deine Zyklusdaten liegen nur auf diesem Server. Exportiere sie jederzeit oder lösche sie endgültig.", + "export": "Meine Zyklusdaten exportieren", + "deleteData": "Meine Zyklusdaten löschen", + "saved": "Einstellungen gespeichert", + "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "save": "Speichern" + } }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", diff --git a/messages/en.json b/messages/en.json index dccfc63de..0dd870fa6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -216,7 +216,8 @@ "medicationDescription": "Mark a dose as taken", "mood": "Mood", "moodDescription": "Log your mood and tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Sign in", @@ -4926,9 +4927,161 @@ "confidenceLow": "Low confidence", "confidenceMedium": "Medium confidence", "confidenceHigh": "High confidence" - } + }, + "title": "Cycle insights", + "empty": "Phase insights appear here once you have enough logged cycles. They compare your metrics across cycle phases, with the statistics shown openly.", + "sampleSize": "n = {n}", + "effectSize": "Effect size {value}", + "qValue": "q = {value}" }, - "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional." + "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.", + "title": "Your cycle", + "subtitle": "Log your period and symptoms, see what comes next.", + "tabs": { + "calendar": "Calendar", + "predictions": "Predictions", + "insights": "Insights", + "settings": "Settings" + }, + "logToday": "Log today", + "startedPeriod": "Started my period today", + "endedPeriod": "Ended my period today", + "phase": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Follicular", + "OVULATORY": "Ovulatory", + "LUTEAL": "Luteal", + "none": "No phase", + "label": "Phase" + }, + "flow": { + "NONE": "None", + "SPOTTING": "Spotting", + "LIGHT": "Light", + "MEDIUM": "Medium", + "HEAVY": "Heavy", + "label": "Flow" + }, + "ovulationTest": { + "NEGATIVE": "Negative", + "POSITIVE_LH_SURGE": "LH surge", + "ESTROGEN_SURGE": "Estrogen surge", + "INDETERMINATE": "Indeterminate", + "label": "Ovulation test" + }, + "mucus": { + "DRY": "Dry", + "STICKY": "Sticky", + "CREAMY": "Creamy", + "WATERY": "Watery", + "EGG_WHITE": "Egg white", + "label": "Cervical mucus" + }, + "goal": { + "GENERAL_HEALTH": "General health", + "AVOID_PREGNANCY": "Avoiding pregnancy", + "TRYING_TO_CONCEIVE": "Trying to conceive", + "PERIMENOPAUSE": "Perimenopause", + "OFF": "Tracking off", + "label": "What are you tracking for?", + "description": "This shapes the language and which predictions you see. Fertile-window guidance shows only when you are trying to conceive." + }, + "ring": { + "dayOfCycle": "Day {day}", + "dayUnknown": "—", + "ariaPhase": "Cycle day {day}, {phase} phase", + "ariaUnknown": "No active cycle yet", + "caption": "of your cycle" + }, + "calendar": { + "legendPeriod": "Logged period", + "legendPredicted": "Predicted period", + "legendFertile": "Fertile window", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptoms logged", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "loading": "Loading your calendar…", + "empty": "Start logging to see your calendar fill in.", + "today": "Today", + "dayAria": "{date}" + }, + "predictions": { + "title": "What comes next", + "nextPeriod": "Next period", + "nextPeriodRange": "Around {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fertile window", + "ovulation": "Estimated ovulation", + "confidence": "Confidence", + "confidenceHigh": "High", + "confidenceMedium": "Medium", + "confidenceLow": "Low", + "stillLearning": "Still learning", + "stillLearningBody": "Log at least three full cycles and your predictions sharpen. Until then this is a rough estimate.", + "rawMode": "Prediction is off", + "rawModeBody": "You turned predictions off — only your logged data is shown, with no forecast.", + "none": "No prediction yet. Log a period to get started." + }, + "history": { + "title": "Your cycles", + "avgLength": "Average length", + "variability": "Variability", + "avgPeriodLength": "Average period", + "regularity": "Regularity", + "regularityREGULAR": "Regular", + "regularityIRREGULAR": "Irregular", + "regularityLEARNING": "Learning", + "days": "{count} days", + "daysPlusMinus": "± {count} days", + "none": "No cycles recorded yet." + }, + "sheet": { + "title": "Log your day", + "description": "Capture today's flow, symptoms, and notes.", + "date": "Date", + "flow": "Flow", + "symptoms": "Symptoms", + "temperature": "Basal body temperature", + "temperaturePlaceholder": "°C", + "ovulationTest": "Ovulation test", + "mucus": "Cervical mucus", + "intercourse": "Intercourse", + "protected": "Protected", + "note": "Note", + "notePlaceholder": "Anything you want to remember about today…", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "saved": "Day saved", + "saveError": "Could not save. Try again.", + "periodStarted": "Period start logged", + "clear": "None" + }, + "settings": { + "title": "Cycle settings", + "enable": "Cycle tracking", + "enableDescription": "Turn the cycle tab on or off for your account.", + "typicalCycleLength": "Typical cycle length (days)", + "typicalPeriodLength": "Typical period length (days)", + "lutealPhaseLength": "Luteal phase length (days)", + "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.", + "prediction": "Predictions", + "predictionDescription": "Forecast your next period and, when relevant, your fertile window.", + "encryption": "Encrypt sensitive entries", + "encryptionDescription": "Encrypt intent-revealing fields (pregnancy tests, contraception, intercourse) at rest, on top of the always-encrypted notes.", + "discreet": "Discreet notifications", + "discreetDescription": "Hide cycle details from notification text on your lock screen.", + "dataTitle": "Your data", + "dataDescription": "Your cycle data lives only on this server. Export it any time, or delete it for good.", + "export": "Export my cycle data", + "deleteData": "Delete my cycle data", + "saved": "Settings saved", + "saveError": "Could not save. Try again.", + "save": "Save" + } }, "cycleReminders": { "periodSoonTitle": "Period likely soon", diff --git a/messages/es.json b/messages/es.json index a37f55d4e..642b8c297 100644 --- a/messages/es.json +++ b/messages/es.json @@ -216,7 +216,8 @@ "medicationDescription": "Marcar una dosis como tomada", "mood": "Estado de ánimo", "moodDescription": "Registrar estado de ánimo y etiquetas" - } + }, + "cycle": "Ciclo" }, "auth": { "login": "Iniciar sesión", @@ -4926,9 +4927,161 @@ "confidenceLow": "Confianza baja", "confidenceMedium": "Confianza media", "confidenceHigh": "Confianza alta" - } + }, + "title": "Análisis del ciclo", + "empty": "Los análisis por fase aparecen aquí cuando tienes suficientes ciclos registrados. Comparan tus métricas entre fases del ciclo, mostrando la estadística abiertamente.", + "sampleSize": "n = {n}", + "effectSize": "Tamaño del efecto {value}", + "qValue": "q = {value}" }, - "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario." + "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", + "title": "Tu ciclo", + "subtitle": "Registra tu periodo y síntomas, y mira qué viene después.", + "tabs": { + "calendar": "Calendario", + "predictions": "Predicciones", + "insights": "Análisis", + "settings": "Ajustes" + }, + "logToday": "Registrar hoy", + "startedPeriod": "Mi periodo empezó hoy", + "endedPeriod": "Mi periodo terminó hoy", + "phase": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Folicular", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Lútea", + "none": "Sin fase", + "label": "Fase" + }, + "flow": { + "NONE": "Ninguno", + "SPOTTING": "Manchado", + "LIGHT": "Ligero", + "MEDIUM": "Medio", + "HEAVY": "Abundante", + "label": "Flujo" + }, + "ovulationTest": { + "NEGATIVE": "Negativo", + "POSITIVE_LH_SURGE": "Pico de LH", + "ESTROGEN_SURGE": "Pico de estrógeno", + "INDETERMINATE": "Indeterminado", + "label": "Test de ovulación" + }, + "mucus": { + "DRY": "Seco", + "STICKY": "Pegajoso", + "CREAMY": "Cremoso", + "WATERY": "Acuoso", + "EGG_WHITE": "Clara de huevo", + "label": "Moco cervical" + }, + "goal": { + "GENERAL_HEALTH": "Salud general", + "AVOID_PREGNANCY": "Evitar el embarazo", + "TRYING_TO_CONCEIVE": "Buscando embarazo", + "PERIMENOPAUSE": "Perimenopausia", + "OFF": "Seguimiento desactivado", + "label": "¿Para qué haces seguimiento?", + "description": "Esto define el lenguaje y qué predicciones ves. La guía de ventana fértil solo aparece si buscas embarazo." + }, + "ring": { + "dayOfCycle": "Día {day}", + "dayUnknown": "—", + "ariaPhase": "Día {day} del ciclo, fase {phase}", + "ariaUnknown": "Aún no hay ciclo activo", + "caption": "de tu ciclo" + }, + "calendar": { + "legendPeriod": "Periodo registrado", + "legendPredicted": "Periodo previsto", + "legendFertile": "Ventana fértil", + "legendOvulation": "Ovulación", + "legendSymptoms": "Síntomas registrados", + "prevMonth": "Mes anterior", + "nextMonth": "Mes siguiente", + "loading": "Cargando tu calendario…", + "empty": "Empieza a registrar para ver tu calendario.", + "today": "Hoy", + "dayAria": "{date}" + }, + "predictions": { + "title": "Qué viene después", + "nextPeriod": "Próximo periodo", + "nextPeriodRange": "Alrededor del {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Ventana fértil", + "ovulation": "Ovulación estimada", + "confidence": "Confianza", + "confidenceHigh": "Alta", + "confidenceMedium": "Media", + "confidenceLow": "Baja", + "stillLearning": "Aún aprendiendo", + "stillLearningBody": "Registra al menos tres ciclos completos y tus predicciones mejorarán. Hasta entonces es una estimación aproximada.", + "rawMode": "Predicción desactivada", + "rawModeBody": "Has desactivado las predicciones — solo se muestran tus datos registrados, sin pronóstico.", + "none": "Aún no hay predicción. Registra un periodo para empezar." + }, + "history": { + "title": "Tus ciclos", + "avgLength": "Duración media", + "variability": "Variabilidad", + "avgPeriodLength": "Periodo medio", + "regularity": "Regularidad", + "regularityREGULAR": "Regular", + "regularityIRREGULAR": "Irregular", + "regularityLEARNING": "Aprendiendo", + "days": "{count} días", + "daysPlusMinus": "± {count} días", + "none": "Aún no hay ciclos registrados." + }, + "sheet": { + "title": "Registrar tu día", + "description": "Captura el flujo, los síntomas y las notas de hoy.", + "date": "Fecha", + "flow": "Flujo", + "symptoms": "Síntomas", + "temperature": "Temperatura basal", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test de ovulación", + "mucus": "Moco cervical", + "intercourse": "Relaciones", + "protected": "Protegidas", + "note": "Nota", + "notePlaceholder": "Algo que quieras recordar de hoy…", + "save": "Guardar", + "saving": "Guardando…", + "cancel": "Cancelar", + "saved": "Día guardado", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "periodStarted": "Inicio del periodo registrado", + "clear": "Ninguno" + }, + "settings": { + "title": "Ajustes del ciclo", + "enable": "Seguimiento del ciclo", + "enableDescription": "Activa o desactiva la pestaña del ciclo en tu cuenta.", + "typicalCycleLength": "Duración típica del ciclo (días)", + "typicalPeriodLength": "Duración típica del periodo (días)", + "lutealPhaseLength": "Duración de la fase lútea (días)", + "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.", + "prediction": "Predicciones", + "predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.", + "encryption": "Cifrar entradas sensibles", + "encryptionDescription": "Cifra en reposo los campos que revelan intención (test de embarazo, anticoncepción, relaciones), además de las notas siempre cifradas.", + "discreet": "Notificaciones discretas", + "discreetDescription": "Oculta los detalles del ciclo en el texto de las notificaciones de tu pantalla de bloqueo.", + "dataTitle": "Tus datos", + "dataDescription": "Tus datos del ciclo viven solo en este servidor. Expórtalos cuando quieras o elimínalos para siempre.", + "export": "Exportar mis datos del ciclo", + "deleteData": "Eliminar mis datos del ciclo", + "saved": "Ajustes guardados", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "save": "Guardar" + } }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", diff --git a/messages/fr.json b/messages/fr.json index b0f4ea28f..9af7ef8e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -216,7 +216,8 @@ "medicationDescription": "Marquer une dose comme prise", "mood": "Humeur", "moodDescription": "Enregistrer l'humeur et les tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Se connecter", @@ -4926,9 +4927,161 @@ "confidenceLow": "Confiance faible", "confidenceMedium": "Confiance moyenne", "confidenceHigh": "Confiance élevée" - } + }, + "title": "Analyses du cycle", + "empty": "Les analyses par phase apparaissent ici une fois assez de cycles enregistrés. Elles comparent tes mesures entre les phases du cycle, avec les statistiques affichées ouvertement.", + "sampleSize": "n = {n}", + "effectSize": "Taille d'effet {value}", + "qValue": "q = {value}" }, - "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé." + "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.", + "title": "Ton cycle", + "subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.", + "tabs": { + "calendar": "Calendrier", + "predictions": "Prévisions", + "insights": "Analyses", + "settings": "Réglages" + }, + "logToday": "Enregistrer aujourd'hui", + "startedPeriod": "Mes règles ont commencé aujourd'hui", + "endedPeriod": "Mes règles se sont terminées aujourd'hui", + "phase": { + "MENSTRUAL": "Menstruelle", + "FOLLICULAR": "Folliculaire", + "OVULATORY": "Ovulatoire", + "LUTEAL": "Lutéale", + "none": "Aucune phase", + "label": "Phase" + }, + "flow": { + "NONE": "Aucun", + "SPOTTING": "Spotting", + "LIGHT": "Léger", + "MEDIUM": "Moyen", + "HEAVY": "Abondant", + "label": "Flux" + }, + "ovulationTest": { + "NEGATIVE": "Négatif", + "POSITIVE_LH_SURGE": "Pic de LH", + "ESTROGEN_SURGE": "Pic d'œstrogène", + "INDETERMINATE": "Indéterminé", + "label": "Test d'ovulation" + }, + "mucus": { + "DRY": "Sec", + "STICKY": "Collant", + "CREAMY": "Crémeux", + "WATERY": "Aqueux", + "EGG_WHITE": "Blanc d'œuf", + "label": "Glaire cervicale" + }, + "goal": { + "GENERAL_HEALTH": "Santé générale", + "AVOID_PREGNANCY": "Éviter une grossesse", + "TRYING_TO_CONCEIVE": "Désir d'enfant", + "PERIMENOPAUSE": "Périménopause", + "OFF": "Suivi désactivé", + "label": "Pourquoi fais-tu ce suivi ?", + "description": "Cela adapte le langage et les prévisions affichées. Les conseils sur la fenêtre fertile n'apparaissent qu'en cas de désir d'enfant." + }, + "ring": { + "dayOfCycle": "Jour {day}", + "dayUnknown": "—", + "ariaPhase": "Jour {day} du cycle, phase {phase}", + "ariaUnknown": "Pas encore de cycle actif", + "caption": "de ton cycle" + }, + "calendar": { + "legendPeriod": "Règles enregistrées", + "legendPredicted": "Règles prévues", + "legendFertile": "Fenêtre fertile", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptômes enregistrés", + "prevMonth": "Mois précédent", + "nextMonth": "Mois suivant", + "loading": "Chargement de ton calendrier…", + "empty": "Commence à enregistrer pour remplir ton calendrier.", + "today": "Aujourd'hui", + "dayAria": "{date}" + }, + "predictions": { + "title": "Ce qui arrive ensuite", + "nextPeriod": "Prochaines règles", + "nextPeriodRange": "Vers le {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fenêtre fertile", + "ovulation": "Ovulation estimée", + "confidence": "Confiance", + "confidenceHigh": "Élevée", + "confidenceMedium": "Moyenne", + "confidenceLow": "Faible", + "stillLearning": "Encore en apprentissage", + "stillLearningBody": "Enregistre au moins trois cycles complets et tes prévisions s'affineront. D'ici là, ceci reste une estimation approximative.", + "rawMode": "Prévision désactivée", + "rawModeBody": "Tu as désactivé les prévisions — seules tes données enregistrées sont affichées, sans prévision.", + "none": "Pas encore de prévision. Enregistre des règles pour commencer." + }, + "history": { + "title": "Tes cycles", + "avgLength": "Durée moyenne", + "variability": "Variabilité", + "avgPeriodLength": "Règles moyennes", + "regularity": "Régularité", + "regularityREGULAR": "Régulier", + "regularityIRREGULAR": "Irrégulier", + "regularityLEARNING": "En apprentissage", + "days": "{count} jours", + "daysPlusMinus": "± {count} jours", + "none": "Aucun cycle enregistré pour l'instant." + }, + "sheet": { + "title": "Enregistrer ta journée", + "description": "Saisis le flux, les symptômes et les notes du jour.", + "date": "Date", + "flow": "Flux", + "symptoms": "Symptômes", + "temperature": "Température basale", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test d'ovulation", + "mucus": "Glaire cervicale", + "intercourse": "Rapports", + "protected": "Protégés", + "note": "Note", + "notePlaceholder": "Ce que tu veux retenir d'aujourd'hui…", + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler", + "saved": "Journée enregistrée", + "saveError": "Enregistrement impossible. Réessaie.", + "periodStarted": "Début des règles enregistré", + "clear": "Aucun" + }, + "settings": { + "title": "Réglages du cycle", + "enable": "Suivi du cycle", + "enableDescription": "Active ou désactive l'onglet cycle pour ton compte.", + "typicalCycleLength": "Durée typique du cycle (jours)", + "typicalPeriodLength": "Durée typique des règles (jours)", + "lutealPhaseLength": "Durée de la phase lutéale (jours)", + "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.", + "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", + "encryptionDescription": "Chiffre au repos les champs révélant l'intention (tests de grossesse, contraception, rapports), en plus des notes toujours chiffrées.", + "discreet": "Notifications discrètes", + "discreetDescription": "Masque les détails du cycle dans le texte des notifications sur ton écran verrouillé.", + "dataTitle": "Tes données", + "dataDescription": "Tes données de cycle ne vivent que sur ce serveur. Exporte-les quand tu veux, ou supprime-les définitivement.", + "export": "Exporter mes données de cycle", + "deleteData": "Supprimer mes données de cycle", + "saved": "Réglages enregistrés", + "saveError": "Enregistrement impossible. Réessaie.", + "save": "Enregistrer" + } }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", diff --git a/messages/it.json b/messages/it.json index ea0d1780c..f5ccfdffb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -216,7 +216,8 @@ "medicationDescription": "Segna una dose come assunta", "mood": "Umore", "moodDescription": "Registra umore e tag" - } + }, + "cycle": "Ciclo" }, "auth": { "login": "Accedi", @@ -4926,9 +4927,161 @@ "confidenceLow": "Affidabilità bassa", "confidenceMedium": "Affidabilità media", "confidenceHigh": "Affidabilità alta" - } + }, + "title": "Analisi del ciclo", + "empty": "Le analisi per fase compaiono qui quando hai registrato abbastanza cicli. Confrontano le tue misure tra le fasi del ciclo, con la statistica mostrata apertamente.", + "sampleSize": "n = {n}", + "effectSize": "Dimensione dell'effetto {value}", + "qValue": "q = {value}" }, - "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario." + "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.", + "title": "Il tuo ciclo", + "subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.", + "tabs": { + "calendar": "Calendario", + "predictions": "Previsioni", + "insights": "Analisi", + "settings": "Impostazioni" + }, + "logToday": "Registra oggi", + "startedPeriod": "Le mestruazioni sono iniziate oggi", + "endedPeriod": "Le mestruazioni sono finite oggi", + "phase": { + "MENSTRUAL": "Mestruale", + "FOLLICULAR": "Follicolare", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Luteale", + "none": "Nessuna fase", + "label": "Fase" + }, + "flow": { + "NONE": "Nessuno", + "SPOTTING": "Spotting", + "LIGHT": "Leggero", + "MEDIUM": "Medio", + "HEAVY": "Abbondante", + "label": "Flusso" + }, + "ovulationTest": { + "NEGATIVE": "Negativo", + "POSITIVE_LH_SURGE": "Picco di LH", + "ESTROGEN_SURGE": "Picco di estrogeni", + "INDETERMINATE": "Indeterminato", + "label": "Test di ovulazione" + }, + "mucus": { + "DRY": "Secco", + "STICKY": "Appiccicoso", + "CREAMY": "Cremoso", + "WATERY": "Acquoso", + "EGG_WHITE": "Albume", + "label": "Muco cervicale" + }, + "goal": { + "GENERAL_HEALTH": "Salute generale", + "AVOID_PREGNANCY": "Evitare la gravidanza", + "TRYING_TO_CONCEIVE": "Cerco una gravidanza", + "PERIMENOPAUSE": "Perimenopausa", + "OFF": "Monitoraggio disattivato", + "label": "Per cosa stai monitorando?", + "description": "Questo definisce il linguaggio e quali previsioni vedi. Le indicazioni sulla finestra fertile compaiono solo se cerchi una gravidanza." + }, + "ring": { + "dayOfCycle": "Giorno {day}", + "dayUnknown": "—", + "ariaPhase": "Giorno {day} del ciclo, fase {phase}", + "ariaUnknown": "Nessun ciclo attivo ancora", + "caption": "del tuo ciclo" + }, + "calendar": { + "legendPeriod": "Ciclo registrato", + "legendPredicted": "Ciclo previsto", + "legendFertile": "Finestra fertile", + "legendOvulation": "Ovulazione", + "legendSymptoms": "Sintomi registrati", + "prevMonth": "Mese precedente", + "nextMonth": "Mese successivo", + "loading": "Caricamento del calendario…", + "empty": "Inizia a registrare per riempire il calendario.", + "today": "Oggi", + "dayAria": "{date}" + }, + "predictions": { + "title": "Cosa arriva dopo", + "nextPeriod": "Prossimo ciclo", + "nextPeriodRange": "Intorno al {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Finestra fertile", + "ovulation": "Ovulazione stimata", + "confidence": "Affidabilità", + "confidenceHigh": "Alta", + "confidenceMedium": "Media", + "confidenceLow": "Bassa", + "stillLearning": "Sto ancora imparando", + "stillLearningBody": "Registra almeno tre cicli completi e le previsioni miglioreranno. Fino ad allora è una stima approssimativa.", + "rawMode": "Previsione disattivata", + "rawModeBody": "Hai disattivato le previsioni — vengono mostrati solo i tuoi dati registrati, senza previsione.", + "none": "Nessuna previsione ancora. Registra un ciclo per iniziare." + }, + "history": { + "title": "I tuoi cicli", + "avgLength": "Durata media", + "variability": "Variabilità", + "avgPeriodLength": "Mestruazioni medie", + "regularity": "Regolarità", + "regularityREGULAR": "Regolare", + "regularityIRREGULAR": "Irregolare", + "regularityLEARNING": "In apprendimento", + "days": "{count} giorni", + "daysPlusMinus": "± {count} giorni", + "none": "Nessun ciclo registrato ancora." + }, + "sheet": { + "title": "Registra la tua giornata", + "description": "Annota flusso, sintomi e note di oggi.", + "date": "Data", + "flow": "Flusso", + "symptoms": "Sintomi", + "temperature": "Temperatura basale", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test di ovulazione", + "mucus": "Muco cervicale", + "intercourse": "Rapporti", + "protected": "Protetti", + "note": "Nota", + "notePlaceholder": "Qualcosa che vuoi ricordare di oggi…", + "save": "Salva", + "saving": "Salvataggio…", + "cancel": "Annulla", + "saved": "Giornata salvata", + "saveError": "Impossibile salvare. Riprova.", + "periodStarted": "Inizio del ciclo registrato", + "clear": "Nessuno" + }, + "settings": { + "title": "Impostazioni del ciclo", + "enable": "Monitoraggio del ciclo", + "enableDescription": "Attiva o disattiva la scheda ciclo per il tuo account.", + "typicalCycleLength": "Durata tipica del ciclo (giorni)", + "typicalPeriodLength": "Durata tipica delle mestruazioni (giorni)", + "lutealPhaseLength": "Durata della fase luteale (giorni)", + "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.", + "prediction": "Previsioni", + "predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.", + "encryption": "Cifra le voci sensibili", + "encryptionDescription": "Cifra a riposo i campi che rivelano l'intenzione (test di gravidanza, contraccezione, rapporti), oltre alle note sempre cifrate.", + "discreet": "Notifiche discrete", + "discreetDescription": "Nascondi i dettagli del ciclo nel testo delle notifiche sulla schermata di blocco.", + "dataTitle": "I tuoi dati", + "dataDescription": "I tuoi dati del ciclo vivono solo su questo server. Esportali quando vuoi, o eliminali per sempre.", + "export": "Esporta i miei dati del ciclo", + "deleteData": "Elimina i miei dati del ciclo", + "saved": "Impostazioni salvate", + "saveError": "Impossibile salvare. Riprova.", + "save": "Salva" + } }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", diff --git a/messages/pl.json b/messages/pl.json index 830347919..01fdf341d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -216,7 +216,8 @@ "medicationDescription": "Oznacz dawkę jako przyjętą", "mood": "Nastrój", "moodDescription": "Zapisz nastrój i tagi" - } + }, + "cycle": "Cykl" }, "auth": { "login": "Zaloguj się", @@ -4926,9 +4927,161 @@ "confidenceLow": "Niska pewność", "confidenceMedium": "Średnia pewność", "confidenceHigh": "Wysoka pewność" - } + }, + "title": "Analizy cyklu", + "empty": "Analizy faz pojawią się tutaj, gdy zapiszesz wystarczająco wiele cykli. Porównują twoje wskaźniki między fazami cyklu, z otwarcie pokazaną statystyką.", + "sampleSize": "n = {n}", + "effectSize": "Wielkość efektu {value}", + "qValue": "q = {value}" }, - "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia." + "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", + "title": "Twój cykl", + "subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.", + "tabs": { + "calendar": "Kalendarz", + "predictions": "Prognozy", + "insights": "Analizy", + "settings": "Ustawienia" + }, + "logToday": "Zapisz dziś", + "startedPeriod": "Miesiączka zaczęła się dzisiaj", + "endedPeriod": "Miesiączka skończyła się dzisiaj", + "phase": { + "MENSTRUAL": "Miesiączkowa", + "FOLLICULAR": "Folikularna", + "OVULATORY": "Owulacyjna", + "LUTEAL": "Lutealna", + "none": "Brak fazy", + "label": "Faza" + }, + "flow": { + "NONE": "Brak", + "SPOTTING": "Plamienie", + "LIGHT": "Słaby", + "MEDIUM": "Średni", + "HEAVY": "Obfity", + "label": "Krwawienie" + }, + "ovulationTest": { + "NEGATIVE": "Negatywny", + "POSITIVE_LH_SURGE": "Skok LH", + "ESTROGEN_SURGE": "Skok estrogenu", + "INDETERMINATE": "Nieokreślony", + "label": "Test owulacyjny" + }, + "mucus": { + "DRY": "Suchy", + "STICKY": "Lepki", + "CREAMY": "Kremowy", + "WATERY": "Wodnisty", + "EGG_WHITE": "Jak białko jaja", + "label": "Śluz szyjkowy" + }, + "goal": { + "GENERAL_HEALTH": "Ogólne zdrowie", + "AVOID_PREGNANCY": "Unikanie ciąży", + "TRYING_TO_CONCEIVE": "Staranie o dziecko", + "PERIMENOPAUSE": "Perimenopauza", + "OFF": "Śledzenie wyłączone", + "label": "W jakim celu śledzisz?", + "description": "To dostosowuje język i widoczne prognozy. Wskazówki dotyczące okna płodnego pojawiają się tylko przy staraniu o dziecko." + }, + "ring": { + "dayOfCycle": "Dzień {day}", + "dayUnknown": "—", + "ariaPhase": "Dzień {day} cyklu, faza {phase}", + "ariaUnknown": "Brak aktywnego cyklu", + "caption": "twojego cyklu" + }, + "calendar": { + "legendPeriod": "Zapisana miesiączka", + "legendPredicted": "Prognozowana miesiączka", + "legendFertile": "Okno płodne", + "legendOvulation": "Owulacja", + "legendSymptoms": "Zapisane objawy", + "prevMonth": "Poprzedni miesiąc", + "nextMonth": "Następny miesiąc", + "loading": "Ładowanie kalendarza…", + "empty": "Zacznij zapisywać, aby wypełnić kalendarz.", + "today": "Dziś", + "dayAria": "{date}" + }, + "predictions": { + "title": "Co dalej", + "nextPeriod": "Następna miesiączka", + "nextPeriodRange": "Około {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Okno płodne", + "ovulation": "Szacowana owulacja", + "confidence": "Pewność", + "confidenceHigh": "Wysoka", + "confidenceMedium": "Średnia", + "confidenceLow": "Niska", + "stillLearning": "Wciąż się uczę", + "stillLearningBody": "Zapisz co najmniej trzy pełne cykle, a prognozy się wyostrzą. Do tego czasu to zgrubne oszacowanie.", + "rawMode": "Prognoza wyłączona", + "rawModeBody": "Wyłączyłaś prognozy — pokazywane są tylko zapisane dane, bez przewidywań.", + "none": "Brak prognozy. Zapisz miesiączkę, aby zacząć." + }, + "history": { + "title": "Twoje cykle", + "avgLength": "Średnia długość", + "variability": "Zmienność", + "avgPeriodLength": "Średnia miesiączka", + "regularity": "Regularność", + "regularityREGULAR": "Regularny", + "regularityIRREGULAR": "Nieregularny", + "regularityLEARNING": "Uczenie", + "days": "{count} dni", + "daysPlusMinus": "± {count} dni", + "none": "Brak zapisanych cykli." + }, + "sheet": { + "title": "Zapisz swój dzień", + "description": "Zapisz dzisiejsze krwawienie, objawy i notatki.", + "date": "Data", + "flow": "Krwawienie", + "symptoms": "Objawy", + "temperature": "Podstawowa temperatura ciała", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test owulacyjny", + "mucus": "Śluz szyjkowy", + "intercourse": "Współżycie", + "protected": "Zabezpieczone", + "note": "Notatka", + "notePlaceholder": "Cokolwiek chcesz zapamiętać z dzisiaj…", + "save": "Zapisz", + "saving": "Zapisywanie…", + "cancel": "Anuluj", + "saved": "Dzień zapisany", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "periodStarted": "Zapisano początek miesiączki", + "clear": "Brak" + }, + "settings": { + "title": "Ustawienia cyklu", + "enable": "Śledzenie cyklu", + "enableDescription": "Włącz lub wyłącz zakładkę cyklu dla swojego konta.", + "typicalCycleLength": "Typowa długość cyklu (dni)", + "typicalPeriodLength": "Typowa długość miesiączki (dni)", + "lutealPhaseLength": "Długość fazy lutealnej (dni)", + "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.", + "prediction": "Prognozy", + "predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.", + "encryption": "Szyfruj wrażliwe wpisy", + "encryptionDescription": "Szyfruj w spoczynku pola ujawniające intencje (testy ciążowe, antykoncepcja, współżycie), obok zawsze szyfrowanych notatek.", + "discreet": "Dyskretne powiadomienia", + "discreetDescription": "Ukryj szczegóły cyklu w treści powiadomień na ekranie blokady.", + "dataTitle": "Twoje dane", + "dataDescription": "Twoje dane cyklu istnieją tylko na tym serwerze. Eksportuj je w każdej chwili lub usuń na zawsze.", + "export": "Eksportuj moje dane cyklu", + "deleteData": "Usuń moje dane cyklu", + "saved": "Ustawienia zapisane", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "save": "Zapisz" + } }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx new file mode 100644 index 000000000..54034d4a5 --- /dev/null +++ b/src/app/cycle/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { CycleView } from "@/components/cycle/cycle-view"; + +/** + * v1.15.0 — the cycle vertical entry. + * + * Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved + * server-side from gender + the per-user opt-in). An unauthenticated visitor + * is bounced to login; an authenticated account without the feature enabled + * is bounced home (the nav entry is already hidden for them, so this only + * catches a direct URL hit). Every `/api/cycle/*` route also enforces the + * gate server-side, so this is a UX redirect, not the security boundary. + */ +export default function CyclePage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.cycleTrackingEnabled === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 4806a0bed..9e6d54b2d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -238,6 +238,17 @@ --ring-red-from: #c93a2a; --ring-red-to: #ff7a6b; + /* + * v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct + * tones for the cycle wheel: menstrual rose · follicular green · ovulatory + * amber-gold · luteal violet. Tuned to read as a soft instrument, never a + * gaudy rainbow — the same restraint as the wellness rings above. + */ + --cycle-phase-menstrual: #ff6e9c; + --cycle-phase-follicular: #5cf08f; + --cycle-phase-ovulatory: #ffce6b; + --cycle-phase-luteal: #bd93f9; + color-scheme: dark; } @@ -312,6 +323,17 @@ --ring-red-from: #a3271a; --ring-red-to: #cb3a2a; + /* + * v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more + * saturated than the dark set so each phase holds AA-grade presence on the + * white card. Same four distinct hues: menstrual rose · follicular green · + * ovulatory amber · luteal violet. + */ + --cycle-phase-menstrual: #c8295e; + --cycle-phase-follicular: #0f7a3a; + --cycle-phase-ovulatory: #a34d14; + --cycle-phase-luteal: #644ac9; + /* * Status-pill text in light mode (Alucard). The bright Dracula * green / orange / red read 1.2–2.7:1 on a near-white tint; these diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx new file mode 100644 index 000000000..7039550ad --- /dev/null +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleCalendar } from "../cycle-calendar"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CalendarDay } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function dayBase(date: string): CalendarDay { + return { + date, + phase: null, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + flow: null, + hasSymptoms: false, + confidence: 1, + }; +} + +describe("", () => { + const today = "2026-06-15"; + + it("renders the month grid for today's month", () => { + const html = render( + {}} />, + ); + expect(html).toContain('data-slot="cycle-calendar"'); + expect(html).toContain('role="grid"'); + // The current day cell is marked. + expect(html).toContain('aria-current="date"'); + }); + + it("labels a logged-period day in its aria-label", () => { + const days = [{ ...dayBase("2026-06-10"), isPeriodLogged: true }]; + const html = render( + {}} />, + ); + expect(html).toContain("Logged period"); + }); + + it("renders the predicted period as a band, not a logged pip", () => { + const days = [{ ...dayBase("2026-06-20"), isPredictedPeriod: true }]; + const html = render( + {}} />, + ); + // The predicted band uses a dashed gradient underline + the aria marker. + expect(html).toContain("Predicted period"); + expect(html).toContain("repeating-linear-gradient"); + }); + + it("labels a fertile-window day (only present when goal-gated server allows it)", () => { + const days = [{ ...dayBase("2026-06-12"), isFertileWindow: true }]; + const html = render( + {}} />, + ); + expect(html).toContain("Fertile window"); + }); + + it("renders a localized Monday-first weekday header row", () => { + const html = render( + {}} />, + ); + expect(html).toContain('role="columnheader"'); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-insights.test.tsx b/src/components/cycle/__tests__/cycle-insights.test.tsx new file mode 100644 index 000000000..2430d347e --- /dev/null +++ b/src/components/cycle/__tests__/cycle-insights.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleInsights } from "../cycle-insights"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the empty-state when no cards are surfaced", () => { + const html = render(); + expect(html).toContain("Phase insights appear here"); + expect(html).not.toContain('data-slot="cycle-insights"'); + }); + + it("renders the card list with the open statistics when cards are present", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-insights"'); + expect(html).toContain("Resting heart rate runs higher"); + expect(html).toContain("n = 42"); + expect(html).toContain("0.61"); + expect(html).toContain("0.040"); + expect(html).toContain("Descriptive only."); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-ring.test.tsx b/src/components/cycle/__tests__/cycle-ring.test.tsx new file mode 100644 index 000000000..4cb0073de --- /dev/null +++ b/src/components/cycle/__tests__/cycle-ring.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleRing } from "../cycle-ring"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the root data-slot tagged with the active phase", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-ring"'); + expect(html).toContain('data-phase="FOLLICULAR"'); + }); + + it("shows the day-of-cycle number as centred text", () => { + const html = render( + , + ); + expect(html).toContain(">12<"); + }); + + it("carries a phase-aware aria-label (never colour-only)", () => { + const html = render( + , + ); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + // The label restates the day + phase. + expect(html).toContain("3"); + }); + + it("renders the no-cycle state for a null day with a fallback aria-label", () => { + const html = render( + , + ); + expect(html).toContain('data-phase="none"'); + expect(html).toContain('role="img"'); + }); +}); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx new file mode 100644 index 000000000..a941b560c --- /dev/null +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PredictionsPanel } from "../predictions-panel"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CyclePrediction, CycleHistoryResponse } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const basePrediction: CyclePrediction = { + method: "CALENDAR", + nextPeriodStart: "2026-07-01", + nextPeriodStartLow: "2026-06-29", + nextPeriodStartHigh: "2026-07-03", + fertileWindowStart: null, + fertileWindowEnd: null, + predictedOvulation: null, + confidence: 0.8, + cyclesObserved: 6, + stillLearning: false, + disclaimer: "Estimates only, not medical advice.", +}; + +const emptyHistory: CycleHistoryResponse = { + cycles: [], + stats: { + avgLengthDays: null, + lengthVariabilityDays: null, + avgPeriodLengthDays: null, + regularity: "LEARNING", + }, +}; + +describe("", () => { + it("renders the next-period range + the fixed disclaimer", () => { + const html = render( + , + ); + 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("renders the fertile window only when the prediction carries one", () => { + const withFertile: CyclePrediction = { + ...basePrediction, + fertileWindowStart: "2026-06-14", + fertileWindowEnd: "2026-06-19", + predictedOvulation: "2026-06-17", + }; + const html = render( + , + ); + expect(html).toContain("Fertile window"); + expect(html).toContain("Estimated ovulation"); + }); + + it("suppresses fertile-window language when the prediction nulls it (goal-gated server-side)", () => { + const html = render( + , + ); + expect(html).not.toContain("Fertile window"); + }); + + it("shows the still-learning state for an under-3-cycle prediction", () => { + const html = render( + , + ); + expect(html).toContain("Still learning"); + }); + + it("shows the raw-chart-mode state when predictions are off", () => { + const html = render( + , + ); + expect(html).toContain("Prediction is off"); + }); +}); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts new file mode 100644 index 000000000..c5ee28772 --- /dev/null +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; + +import { deriveWheelState } from "../wheel-state"; +import type { CalendarDay } from "../types"; + +function day(date: string, phase: CalendarDay["phase"]): CalendarDay { + return { + date, + phase, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: phase === "MENSTRUAL", + flow: null, + hasSymptoms: false, + confidence: 1, + }; +} + +describe("deriveWheelState", () => { + it("returns a null state when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + const w = deriveWheelState(days, "2026-06-02"); + expect(w.dayOfCycle).toBeNull(); + expect(w.phase).toBeNull(); + expect(w.spans).toEqual([]); + }); + + it("counts the day-of-cycle from the most recent menstrual start", () => { + // A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5. + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + ]; + const w = deriveWheelState(days, "2026-06-05"); + expect(w.dayOfCycle).toBe(5); + expect(w.phase).toBe("FOLLICULAR"); + }); + + it("builds proportional phase spans summing to ~1", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "OVULATORY"), + day("2026-06-05", "LUTEAL"), + ]; + const w = deriveWheelState(days, "2026-06-03"); + const total = w.spans.reduce((s, x) => s + x.fraction, 0); + expect(total).toBeCloseTo(1, 5); + // Only phases present in the run are emitted. + expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL"); + }); +}); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx new file mode 100644 index 000000000..ed4b43d83 --- /dev/null +++ b/src/components/cycle/cycle-calendar.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CalendarDay } from "./types"; +import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; + +/** + * v1.15.0 — the month calendar. + * + * Renders one month at a time over the calendar read's day grid. Logged + * period days carry a filled rose pip; the predicted next period renders as + * a ranged BAND (a soft rose underline spanning the predicted days, never a + * single dated dot); the fertile window (already goal-gated server-side — the + * API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the + * predicted-ovulation day gets an amber marker; per-day symptom presence + * shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet). + * + * a11y: each day is a real ` + {monthLabel} + + + +
+ {weekdays.map((wk, i) => ( +
+ {wk} +
+ ))} + + {grid.map((cell, i) => { + if (!cell) return + + +
+ ); +} + +function CalendarLegend() { + const { t } = useTranslations(); + const items: { hue: string; labelKey: string; dashed?: boolean }[] = [ + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod" }, + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPredicted", dashed: true }, + { hue: FERTILE_HUE, labelKey: "cycle.calendar.legendFertile" }, + { hue: OVULATION_HUE, labelKey: "cycle.calendar.legendOvulation" }, + ]; + return ( +
    + {items.map((it) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/src/components/cycle/cycle-disclaimer.tsx b/src/components/cycle/cycle-disclaimer.tsx new file mode 100644 index 000000000..371fcd9eb --- /dev/null +++ b/src/components/cycle/cycle-disclaimer.tsx @@ -0,0 +1,32 @@ +"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 ( +

+ {text} +

+ ); +} diff --git a/src/components/cycle/cycle-insights.tsx b/src/components/cycle/cycle-insights.tsx new file mode 100644 index 000000000..e18954671 --- /dev/null +++ b/src/components/cycle/cycle-insights.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CycleInsightCard } from "./types"; + +/** + * v1.15.0 — phase-correlation insight cards. + * + * Renders whatever phase-correlation cards the API surfaces (another wave + * adds the data feed; this UI renders the list gracefully and shows the + * empty-state when none). Each card carries the open statistics — sample + * size, effect size, q-value — alongside the prose, matching the + * FDR-guarded honest-reporting posture of the rest of Insights. + */ +export function CycleInsights({ + cards, +}: { + cards: CycleInsightCard[]; +}) { + const { t } = useTranslations(); + + if (cards.length === 0) { + return ( + + + + + + +

+ {t("cycle.insights.empty")} +

+
+
+ ); + } + + return ( +
+ {cards.map((card) => ( + + + {card.title} + + +

{card.body}

+
+ {card.n != null ? ( + {t("cycle.insights.sampleSize", { n: card.n })} + ) : null} + {card.effectSize != null ? ( + + {t("cycle.insights.effectSize", { + value: card.effectSize.toFixed(2), + })} + + ) : null} + {card.qValue != null ? ( + + {t("cycle.insights.qValue", { + value: card.qValue.toFixed(3), + })} + + ) : null} +
+ {card.caveat ? ( +

+ {card.caveat} +

+ ) : null} +
+
+ ))} +
+ ); +} diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx new file mode 100644 index 000000000..051ad38c4 --- /dev/null +++ b/src/components/cycle/cycle-ring.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import type { CyclePhase } from "./types"; +import { PHASE_HUE } from "./phase-tokens"; + +/** + * v1.15.0 — the cycle wheel: a hand-rolled SVG phase ring (0 KB runtime, no + * chart lib), matching the premium wellness-ring aesthetic (calm distinct + * hues, thin round-cap arcs, a faint segmented track). Unlike the 0–100 + * score gauge this is a FULL-circle dial: the four cycle phases render as + * proportional arc segments around the ring, and a marker dot sits at the + * current day-of-cycle. The day number is real centred DOM text. + * + * a11y: `role="img"` + a phase-aware aria-label so the ring is never + * colour-only. The marker + segments are JSX SVG (`` is NOT + * `dangerouslySetInnerHTML`). + */ + +const VIEW = 100; +const STROKE = 8; +const R = (VIEW - STROKE) / 2; +const C = 2 * Math.PI * R; +const CX = VIEW / 2; +const GAP = 0.5; // unit gap between segments so phases read as distinct arcs. + +const PHASE_ORDER: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]; + +export interface PhaseSpan { + phase: CyclePhase; + /** Proportion of the cycle this phase covers (0..1). Spans sum to ~1. */ + fraction: number; +} + +export interface CycleRingProps { + /** 1-based day of the current cycle, or null when no cycle is active. */ + dayOfCycle: number | null; + /** Total length the ring represents (estimated cycle length, days). */ + cycleLength: number | null; + /** The current phase (drives the centre label + aria). */ + phase: CyclePhase | null; + /** Proportional phase spans around the ring. Omit for an even quarter split. */ + spans?: PhaseSpan[]; + size?: number; + className?: string; +} + +function evenSpans(): PhaseSpan[] { + return PHASE_ORDER.map((phase) => ({ phase, fraction: 0.25 })); +} + +export function CycleRing({ + dayOfCycle, + cycleLength, + phase, + spans, + size = 220, + className, +}: CycleRingProps) { + const { t } = useTranslations(); + const reduced = prefersReducedMotion(); + + const resolvedSpans = useMemo(() => { + const raw = spans && spans.length > 0 ? spans : evenSpans(); + const total = raw.reduce((s, x) => s + x.fraction, 0) || 1; + return raw.map((x) => ({ ...x, fraction: x.fraction / total })); + }, [spans]); + + // Build each phase arc as a dash segment around the ring. The running + // start-offset is carried through `reduce` rather than mutating a closure + // variable inside `map` (the latter trips the render-immutability rule). + const segments = useMemo( + () => + resolvedSpans.reduce< + { + offset: number; + segs: { + phase: CyclePhase; + dashArray: string; + dashOffset: number; + active: boolean; + }[]; + } + >( + (acc, span) => { + const len = Math.max(span.fraction * C - GAP, 0); + acc.segs.push({ + phase: span.phase, + dashArray: `${len} ${C - len}`, + dashOffset: -acc.offset, + active: span.phase === phase, + }); + return { + offset: acc.offset + span.fraction * C, + segs: acc.segs, + }; + }, + { offset: 0, segs: [] }, + ).segs, + [resolvedSpans, phase], + ); + + // The marker sits at the cycle-day proportion around the circle. + const markerProgress = + dayOfCycle != null && cycleLength != null && cycleLength > 0 + ? Math.min(dayOfCycle / cycleLength, 1) + : null; + const markerAngle = + markerProgress != null ? markerProgress * 2 * Math.PI - Math.PI / 2 : null; + const markerX = markerAngle != null ? CX + R * Math.cos(markerAngle) : 0; + const markerY = markerAngle != null ? CX + R * Math.sin(markerAngle) : 0; + + const phaseLabel = phase + ? t(`cycle.phase.${phase}`) + : t("cycle.phase.none"); + + const ariaLabel = + dayOfCycle != null + ? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseLabel }) + : t("cycle.ring.ariaUnknown"); + + return ( +
+ + {/* Centred day number + phase caption as real DOM text. */} +
+ + {dayOfCycle != null ? dayOfCycle : t("cycle.ring.dayUnknown")} + + + {phaseLabel} + +
+
+ ); +} diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx new file mode 100644 index 000000000..4636e00b4 --- /dev/null +++ b/src/components/cycle/cycle-settings.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Loader2 } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +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 { useUpdateCyclePrefs } from "./use-cycle"; + +/** + * v1.15.0 — the single-page cycle settings form. + * + * One dynamic form, no top/bottom split (the project's settings-UX rule): + * the inclusive goal selector drives all copy — fertile-window language only + * appears for TRYING_TO_CONCEIVE; GENERAL_HEALTH / PERIMENOPAUSE / OFF hide + * conception framing. Priors, raw-chart mode, sensitive-category encryption, + * discreet notifications, and the data export/delete affordances all live on + * the one page. PATCHes `/api/auth/me/cycle-prefs`. + * + * The form seeds its local state from `profile` once on mount; the parent + * passes `key={profile.updatedAt}` so a profile refetch remounts the form + * with the fresh values rather than syncing through an effect. + */ + +const GOALS: CycleGoal[] = [ + "GENERAL_HEALTH", + "AVOID_PREGNANCY", + "TRYING_TO_CONCEIVE", + "PERIMENOPAUSE", + "OFF", +]; + +function numOrEmpty(v: number | null): string { + return v == null ? "" : String(v); +} + +export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + const { t } = useTranslations(); + const update = useUpdateCyclePrefs(); + + const [goal, setGoal] = useState(profile.goal); + const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode); + const [predictionEnabled, setPredictionEnabled] = useState( + profile.predictionEnabled, + ); + const [encryption, setEncryption] = useState( + profile.sensitiveCategoryEncryption, + ); + const [discreet, setDiscreet] = useState(profile.discreetNotifications); + const [cycleLen, setCycleLen] = useState( + numOrEmpty(profile.typicalCycleLength), + ); + const [periodLen, setPeriodLen] = useState( + numOrEmpty(profile.typicalPeriodLength), + ); + const [lutealLen, setLutealLen] = useState( + numOrEmpty(profile.lutealPhaseLength), + ); + + function parsePrior(v: string): number | null { + const n = Number(v); + return v.trim() === "" || !Number.isFinite(n) ? null : Math.round(n); + } + + async function handleSave() { + await update.mutateAsync({ + goal, + rawChartMode, + predictionEnabled, + sensitiveCategoryEncryption: encryption, + discreetNotifications: discreet, + typicalCycleLength: parsePrior(cycleLen), + typicalPeriodLength: parsePrior(periodLen), + lutealPhaseLength: parsePrior(lutealLen), + }); + } + + return ( + + + {t("cycle.settings.title")} + {t("cycle.goal.description")} + + + {/* Inclusive goal selector — drives the rest of the copy. */} +
+ + setGoal(e.target.value as CycleGoal)} + > + {GOALS.map((g) => ( + + ))} + +
+ + + + {/* Predictions — when the goal hides conception framing, the + description never names a fertile window. */} + + + + + + {/* Priors */} +
+

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

+
+ + + +
+
+ + + + {/* Privacy toggles */} + + + + + + {/* Data export / delete */} +
+

{t("cycle.settings.dataTitle")}

+

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

+
+ + +
+
+ +
+ {update.isSuccess ? ( + + {t("cycle.settings.saved")} + + ) : null} + {update.isError ? ( + + {t("cycle.settings.saveError")} + + ) : null} + +
+
+
+ ); +} + +function ToggleRow({ + id, + label, + description, + checked, + onChange, +}: { + id: string; + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+ +

{description}

+
+ +
+ ); +} + +function PriorField({ + id, + label, + value, + onChange, + min, + max, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + min: number; + max: number; +}) { + return ( +
+ + onChange(e.target.value)} + className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2" + /> +
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx new file mode 100644 index 000000000..b9d4c57b6 --- /dev/null +++ b/src/components/cycle/cycle-view.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2, Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useTranslations } from "@/lib/i18n/context"; +import { CycleRing } from "./cycle-ring"; +import { CycleCalendar } from "./cycle-calendar"; +import { LogDaySheet } from "./log-day-sheet"; +import { PredictionsPanel } from "./predictions-panel"; +import { CycleInsights } from "./cycle-insights"; +import { CycleSettings } from "./cycle-settings"; +import { deriveWheelState } from "./wheel-state"; +import { + useCycleCalendar, + useCycleHistory, + useCycleProfile, +} from "./use-cycle"; +import type { CycleInsightCard } from "./types"; + +/** + * v1.15.0 — the cycle vertical's client orchestrator. + * + * Holds the four tabs (Calendar · Predictions · Insights · Settings), the + * wheel + month calendar above the tab strip, and the log-day sheet. The + * calendar read drives the ring (day-of-cycle + phase) and the grid; the + * predictions read powers the panel; history powers the stats. The page-level + * RSC has already gated on `cycleTrackingEnabled`. + */ + +/** YYYY-MM-DD for `n` days from now in the local tz. */ +function shiftToday(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} + +export function CycleView() { + const { t } = useTranslations(); + + const today = useMemo(() => shiftToday(0), []); + const from = useMemo(() => shiftToday(-90), []); + const to = useMemo(() => shiftToday(180), []); + + const calendar = useCycleCalendar(from, to); + const history = useCycleHistory(); + const profileQuery = useCycleProfile(); + + const [sheetOpen, setSheetOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(today); + + // Phase-correlation cards arrive from another wave; render the graceful + // empty-state until the feed lands. + const insightCards: CycleInsightCard[] = []; + + const wheel = useMemo( + () => deriveWheelState(calendar.data?.days ?? [], today), + [calendar.data, today], + ); + + function openSheet(date: string) { + setSelectedDate(date); + setSheetOpen(true); + } + + const loading = calendar.isLoading && !calendar.data; + + return ( +
+
+
+

+ {t("cycle.title")} +

+

+ {t("cycle.subtitle")} +

+
+ +
+ + {/* Wheel — the signature ring above the tabs. */} + + + {loading ? ( +
+ +
+ ) : ( + + )} +

+ {t("cycle.ring.caption")} +

+
+
+ + + + + {t("cycle.tabs.calendar")} + + + {t("cycle.tabs.predictions")} + + + {t("cycle.tabs.insights")} + + + {t("cycle.tabs.settings")} + + + + + + + {loading ? ( +

+ {t("cycle.calendar.loading")} +

+ ) : ( + + )} +
+
+
+ + + + + + + + + + + {profileQuery.data ? ( + + ) : ( +
+ +
+ )} +
+
+ + +
+ ); +} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx new file mode 100644 index 000000000..d0225ac6e --- /dev/null +++ b/src/components/cycle/log-day-sheet.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useState } from "react"; +import { Droplets, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; +import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { FLOW_HUE } from "./phase-tokens"; +import { useLogDay, useStartPeriod } from "./use-cycle"; +import type { + CervicalMucus, + CycleDayLogInput, + FlowLevel, + OvulationTest, +} from "./types"; + +/** + * v1.15.0 — the fast-entry log-day sheet. + * + * One-tap "started my period today" posts the period-boundary shortcut; the + * detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus, + * intercourse + protection, free-text note). The write is optimistic-ish + * (the mutation invalidates the cycle keys on success so the calendar + + * wheel repaint); the symptom chips reuse the mood-tag chip styling. + * + * a11y: every chip is a real ` + ); +} + +export function LogDaySheet({ + open, + onOpenChange, + date, + today, +}: LogDaySheetProps) { + const { t } = useTranslations(); + const logDay = useLogDay(); + const startPeriod = useStartPeriod(); + + const [flow, setFlow] = useState(null); + const [symptoms, setSymptoms] = useState>(new Set()); + const [bbt, setBbt] = useState(""); + const [opk, setOpk] = useState(null); + const [mucus, setMucus] = useState(null); + const [intercourse, setIntercourse] = useState(false); + const [protectedSex, setProtectedSex] = useState(false); + const [note, setNote] = useState(""); + + // Reset the form to empty whenever the sheet (re)opens on a day. Tracked as + // an "adjust state when a prop changes" during render (React's recommended + // alternative to a setState-in-effect) keyed on the open+date pair, so a + // re-open on a different calendar day always starts blank. + const formKey = open ? date : null; + const [lastFormKey, setLastFormKey] = useState(null); + if (open && formKey !== lastFormKey) { + setLastFormKey(formKey); + setFlow(null); + setSymptoms(new Set()); + setBbt(""); + setOpk(null); + setMucus(null); + setIntercourse(false); + setProtectedSex(false); + setNote(""); + } else if (!open && lastFormKey !== null) { + setLastFormKey(null); + } + + function toggleSymptom(key: string) { + setSymptoms((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + async function handleSave() { + const bbtNum = bbt.trim() === "" ? undefined : Number(bbt); + const input: CycleDayLogInput = { + date, + loggedAt: new Date().toISOString(), + source: "MANUAL", + ...(flow ? { flow } : {}), + ...(bbtNum != null && Number.isFinite(bbtNum) + ? { basalBodyTempC: bbtNum } + : {}), + ...(opk ? { ovulationTest: opk } : {}), + ...(mucus ? { cervicalMucus: mucus } : {}), + ...(intercourse + ? { sexualActivity: true, protectedSex } + : {}), + ...(symptoms.size > 0 + ? { symptoms: Array.from(symptoms).map((key) => ({ key })) } + : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }; + await logDay.mutateAsync(input); + onOpenChange(false); + } + + async function handleStartPeriod() { + await startPeriod.mutateAsync(today); + onOpenChange(false); + } + + const busy = logDay.isPending || startPeriod.isPending; + + return ( + + + + + } + > + {/* One-tap period start — only meaningful for today. */} + {date === today ? ( + + ) : null} + + {/* Flow */} + +
+ {FLOW_LEVELS.map((f) => ( + setFlow((cur) => (cur === f ? null : f))} + > + {t(`cycle.flow.${f}`)} + + ))} +
+
+ + {/* Symptoms */} + +
+ {CYCLE_SYMPTOM_CATALOG.map((cat) => ( +
+
+
+
+ {cat.symptoms.map((s) => ( + toggleSymptom(s.key)} + > + + + + ))} +
+
+ ))} +
+
+ + {/* BBT */} + + 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:outline-none focus-visible:ring-2" + aria-label={t("cycle.sheet.temperature")} + /> + + + {/* Ovulation test */} + +
+ {OPK_VALUES.map((v) => ( + setOpk((cur) => (cur === v ? null : v))} + > + {t(`cycle.ovulationTest.${v}`)} + + ))} +
+
+ + {/* Cervical mucus */} + +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ + {/* Intercourse + protection */} + +
+
+ + +
+ {intercourse ? ( +
+ + +
+ ) : null} +
+
+ + {/* Note */} + +