mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
6fdbb8d889
commit
7333d8e40f
35 files changed
+3613
-22
No files matched your search
+156
-3
@@ -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",
|
||||
|
||||
+156
-3
@@ -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",
|
||||
|
||||
+156
-3
@@ -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",
|
||||
|
||||
+156
-3
@@ -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",
|
||||
|
||||
+156
-3
@@ -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",
|
||||
|
||||
+156
-3
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CycleView />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function dayBase(date: string): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase: null,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<CycleCalendar>", () => {
|
||||
const today = "2026-06-15";
|
||||
|
||||
it("renders the month grid for today's month", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
// 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(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("renders a localized Monday-first weekday header row", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('role="columnheader"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CycleInsights>", () => {
|
||||
it("renders the empty-state when no cards are surfaced", () => {
|
||||
const html = render(<CycleInsights cards={[]} />);
|
||||
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(
|
||||
<CycleInsights
|
||||
cards={[
|
||||
{
|
||||
id: "rhr-luteal",
|
||||
title: "Resting heart rate runs higher in your luteal phase",
|
||||
body: "Across your logged cycles RHR is ~3 bpm higher in luteal vs follicular days.",
|
||||
n: 42,
|
||||
effectSize: 0.61,
|
||||
qValue: 0.04,
|
||||
caveat: "Descriptive only.",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CycleRing>", () => {
|
||||
it("renders the root data-slot tagged with the active phase", () => {
|
||||
const html = render(
|
||||
<CycleRing
|
||||
dayOfCycle={8}
|
||||
cycleLength={28}
|
||||
phase="FOLLICULAR"
|
||||
spans={[
|
||||
{ phase: "MENSTRUAL", fraction: 0.18 },
|
||||
{ phase: "FOLLICULAR", fraction: 0.32 },
|
||||
{ phase: "OVULATORY", fraction: 0.1 },
|
||||
{ phase: "LUTEAL", fraction: 0.4 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<CycleRing dayOfCycle={12} cycleLength={28} phase="OVULATORY" />,
|
||||
);
|
||||
expect(html).toContain(">12<");
|
||||
});
|
||||
|
||||
it("carries a phase-aware aria-label (never colour-only)", () => {
|
||||
const html = render(
|
||||
<CycleRing dayOfCycle={3} cycleLength={28} phase="MENSTRUAL" />,
|
||||
);
|
||||
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(
|
||||
<CycleRing dayOfCycle={null} cycleLength={null} phase={null} />,
|
||||
);
|
||||
expect(html).toContain('data-phase="none"');
|
||||
expect(html).toContain('role="img"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<PredictionsPanel>", () => {
|
||||
it("renders the next-period range + the fixed disclaimer", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PredictionsPanel
|
||||
prediction={withFertile}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("shows the still-learning state for an under-3-cycle prediction", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={{ ...basePrediction, stillLearning: true }}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Still learning");
|
||||
});
|
||||
|
||||
it("shows the raw-chart-mode state when predictions are off", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={null}
|
||||
rawChartMode={true}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Prediction is off");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<button>` with an aria-label restating the date
|
||||
* + its markers; the colour markers are paired with the aria text so the
|
||||
* grid is never colour-only. WCAG 2.5.5 — each cell is ≥ 40 px.
|
||||
*/
|
||||
|
||||
/** Monday-first short weekday labels, localized via Intl (no i18n key). */
|
||||
function shortWeekdays(): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
// 2024-01-01 is a Monday — walk 7 days for a Monday-first header row.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(2024, 0, 1 + i)),
|
||||
);
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 0=Mon … 6=Sun (Monday-first grid). */
|
||||
function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export interface CycleCalendarProps {
|
||||
days: CalendarDay[];
|
||||
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
|
||||
today: string;
|
||||
onSelectDay: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CycleCalendar({
|
||||
days,
|
||||
today,
|
||||
onSelectDay,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
const { t } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(), []);
|
||||
const byDate = useMemo(
|
||||
() => new Map(days.map((d) => [d.date, d])),
|
||||
[days],
|
||||
);
|
||||
|
||||
// Anchor the visible month on today's month.
|
||||
const [monthAnchor, setMonthAnchor] = useState(() => {
|
||||
const [y, m] = today.split("-").map(Number);
|
||||
return new Date(y, m - 1, 1);
|
||||
});
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const first = new Date(
|
||||
monthAnchor.getFullYear(),
|
||||
monthAnchor.getMonth(),
|
||||
1,
|
||||
);
|
||||
const lead = mondayIndex(first);
|
||||
const daysInMonth = new Date(
|
||||
monthAnchor.getFullYear(),
|
||||
monthAnchor.getMonth() + 1,
|
||||
0,
|
||||
).getDate();
|
||||
const cells: (Date | null)[] = [];
|
||||
for (let i = 0; i < lead; i++) cells.push(null);
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells.push(
|
||||
new Date(monthAnchor.getFullYear(), monthAnchor.getMonth(), day),
|
||||
);
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
return cells;
|
||||
}, [monthAnchor]);
|
||||
|
||||
const monthLabel = monthAnchor.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
setMonthAnchor(
|
||||
(a) => new Date(a.getFullYear(), a.getMonth() + delta, 1),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="cycle-calendar" className={cn("space-y-3", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("cycle.calendar.prevMonth")}
|
||||
onClick={() => shiftMonth(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold capitalize">{monthLabel}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("cycle.calendar.nextMonth")}
|
||||
onClick={() => shiftMonth(1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="grid"
|
||||
aria-label={monthLabel}
|
||||
className="grid grid-cols-7 gap-1"
|
||||
>
|
||||
{weekdays.map((wk, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="columnheader"
|
||||
className="text-muted-foreground pb-1 text-center text-[11px] font-medium uppercase"
|
||||
>
|
||||
{wk}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{grid.map((cell, i) => {
|
||||
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
|
||||
const date = ymd(cell);
|
||||
const info = byDate.get(date);
|
||||
const isToday = date === today;
|
||||
const markers: string[] = [];
|
||||
if (info?.isPeriodLogged) markers.push(t("cycle.calendar.legendPeriod"));
|
||||
if (info?.isPredictedPeriod)
|
||||
markers.push(t("cycle.calendar.legendPredicted"));
|
||||
if (info?.isFertileWindow)
|
||||
markers.push(t("cycle.calendar.legendFertile"));
|
||||
if (info?.isPredictedOvulation)
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-label={aria}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
"relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:outline-none focus-visible:ring-2",
|
||||
info?.isFertileWindow && "ring-1 ring-inset",
|
||||
isToday && "font-semibold",
|
||||
)}
|
||||
style={
|
||||
info?.isFertileWindow
|
||||
? ({ "--tw-ring-color": FERTILE_HUE } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Logged-period filled pip behind the number. */}
|
||||
{info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md opacity-20"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10",
|
||||
isToday && "text-primary",
|
||||
)}
|
||||
>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
|
||||
{/* Predicted-period band: a soft underline, never a dot. */}
|
||||
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1.5 bottom-1 h-0.5 rounded-full opacity-70"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${FLOW_HUE} 0 3px, transparent 3px 5px)`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Marker row: ovulation + symptom dots. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
|
||||
>
|
||||
{info?.isPredictedOvulation ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CalendarLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||
{items.map((it) => (
|
||||
<li key={it.labelKey} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("h-2.5 w-2.5 rounded-full", it.dashed && "opacity-60")}
|
||||
style={{ backgroundColor: it.hue }}
|
||||
/>
|
||||
{t(it.labelKey)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
data-slot="cycle-disclaimer"
|
||||
className={cn(
|
||||
"text-foreground/80 border-border bg-muted/40 rounded-md border-l-2 px-3 py-2 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="text-primary h-4 w-4" aria-hidden="true" />
|
||||
{t("cycle.insights.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.insights.empty")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="cycle-insights">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{card.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-foreground/90 text-sm">{card.body}</p>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs tabular-nums">
|
||||
{card.n != null ? (
|
||||
<span>{t("cycle.insights.sampleSize", { n: card.n })}</span>
|
||||
) : null}
|
||||
{card.effectSize != null ? (
|
||||
<span>
|
||||
{t("cycle.insights.effectSize", {
|
||||
value: card.effectSize.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{card.qValue != null ? (
|
||||
<span>
|
||||
{t("cycle.insights.qValue", {
|
||||
value: card.qValue.toFixed(3),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{card.caveat ? (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
{card.caveat}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (`<linearGradient>` 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 (
|
||||
<div
|
||||
data-slot="cycle-ring"
|
||||
data-phase={phase ?? "none"}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
className="h-full w-full -rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Faint segmented tick track — reads as an instrument. */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.12}
|
||||
strokeWidth={STROKE}
|
||||
strokeDasharray="0.5 4.2"
|
||||
/>
|
||||
{/* Phase arcs — one calm hue per phase, the active phase fully opaque. */}
|
||||
{segments.map((seg) => (
|
||||
<circle
|
||||
key={seg.phase}
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={PHASE_HUE[seg.phase]}
|
||||
strokeOpacity={seg.active ? 1 : 0.32}
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={seg.dashArray}
|
||||
strokeDashoffset={seg.dashOffset}
|
||||
style={{
|
||||
transition: reduced ? undefined : "stroke-opacity 400ms ease",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Current-day marker dot. */}
|
||||
{markerAngle != null ? (
|
||||
<circle
|
||||
cx={markerX}
|
||||
cy={markerY}
|
||||
r={STROKE / 1.7}
|
||||
fill="var(--background)"
|
||||
stroke={phase ? PHASE_HUE[phase] : "var(--foreground)"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
{/* Centred day number + phase caption as real DOM text. */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
|
||||
{dayOfCycle != null ? dayOfCycle : t("cycle.ring.dayUnknown")}
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-0.5 text-xs">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CycleGoal>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("cycle.settings.title")}</CardTitle>
|
||||
<CardDescription>{t("cycle.goal.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Inclusive goal selector — drives the rest of the copy. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cycle-goal">{t("cycle.goal.label")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-goal"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value as CycleGoal)}
|
||||
>
|
||||
{GOALS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{t(`cycle.goal.${g}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Predictions — when the goal hides conception framing, the
|
||||
description never names a fertile window. */}
|
||||
<ToggleRow
|
||||
id="cycle-prediction"
|
||||
label={t("cycle.settings.prediction")}
|
||||
description={t("cycle.settings.predictionDescription")}
|
||||
checked={predictionEnabled}
|
||||
onChange={setPredictionEnabled}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="cycle-rawchart"
|
||||
label={t("cycle.settings.rawChartMode")}
|
||||
description={t("cycle.settings.rawChartModeDescription")}
|
||||
checked={rawChartMode}
|
||||
onChange={setRawChartMode}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Priors */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("cycle.settings.priorsDescription")}
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<PriorField
|
||||
id="cycle-typical-length"
|
||||
label={t("cycle.settings.typicalCycleLength")}
|
||||
value={cycleLen}
|
||||
onChange={setCycleLen}
|
||||
min={15}
|
||||
max={60}
|
||||
/>
|
||||
<PriorField
|
||||
id="cycle-period-length"
|
||||
label={t("cycle.settings.typicalPeriodLength")}
|
||||
value={periodLen}
|
||||
onChange={setPeriodLen}
|
||||
min={1}
|
||||
max={15}
|
||||
/>
|
||||
<PriorField
|
||||
id="cycle-luteal-length"
|
||||
label={t("cycle.settings.lutealPhaseLength")}
|
||||
value={lutealLen}
|
||||
onChange={setLutealLen}
|
||||
min={8}
|
||||
max={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Privacy toggles */}
|
||||
<ToggleRow
|
||||
id="cycle-encryption"
|
||||
label={t("cycle.settings.encryption")}
|
||||
description={t("cycle.settings.encryptionDescription")}
|
||||
checked={encryption}
|
||||
onChange={setEncryption}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="cycle-discreet"
|
||||
label={t("cycle.settings.discreet")}
|
||||
description={t("cycle.settings.discreetDescription")}
|
||||
checked={discreet}
|
||||
onChange={setDiscreet}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Data export / delete */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t("cycle.settings.dataTitle")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.settings.dataDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/api/export/health-record" download>
|
||||
{t("cycle.settings.export")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/settings/account">
|
||||
{t("cycle.settings.deleteData")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
{update.isSuccess ? (
|
||||
<span className="text-muted-foreground text-sm" role="status">
|
||||
{t("cycle.settings.saved")}
|
||||
</span>
|
||||
) : null}
|
||||
{update.isError ? (
|
||||
<span className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.settings.saveError")}
|
||||
</span>
|
||||
) : null}
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={id} className="text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openSheet(today)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("cycle.logToday")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Wheel — the signature ring above the tabs. */}
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-6">
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.ring.caption")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="calendar">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="calendar" className="flex-1">
|
||||
{t("cycle.tabs.calendar")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="predictions" className="flex-1">
|
||||
{t("cycle.tabs.predictions")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="insights" className="flex-1">
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("cycle.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calendar" className="mt-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.calendar.loading")}
|
||||
</p>
|
||||
) : (
|
||||
<CycleCalendar
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
onSelectDay={openSheet}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="predictions" className="mt-4">
|
||||
<PredictionsPanel
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="insights" className="mt-4">
|
||||
<CycleInsights cards={insightCards} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
{profileQuery.data ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<LogDaySheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<button>` with `aria-pressed`; the sheet
|
||||
* footer sticky-pins Save/Cancel.
|
||||
*/
|
||||
|
||||
const FLOW_LEVELS: FlowLevel[] = ["SPOTTING", "LIGHT", "MEDIUM", "HEAVY"];
|
||||
const OPK_VALUES: OvulationTest[] = [
|
||||
"NEGATIVE",
|
||||
"POSITIVE_LH_SURGE",
|
||||
"ESTROGEN_SURGE",
|
||||
"INDETERMINATE",
|
||||
];
|
||||
const MUCUS_VALUES: CervicalMucus[] = [
|
||||
"DRY",
|
||||
"STICKY",
|
||||
"CREAMY",
|
||||
"WATERY",
|
||||
"EGG_WHITE",
|
||||
];
|
||||
|
||||
export interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The date being logged (YYYY-MM-DD). */
|
||||
date: string;
|
||||
/** Today (YYYY-MM-DD) — gates the one-tap period-start affordance. */
|
||||
today: string;
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogDaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
date,
|
||||
today,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const logDay = useLogDay();
|
||||
const startPeriod = useStartPeriod();
|
||||
|
||||
const [flow, setFlow] = useState<FlowLevel | null>(null);
|
||||
const [symptoms, setSymptoms] = useState<Set<string>>(new Set());
|
||||
const [bbt, setBbt] = useState<string>("");
|
||||
const [opk, setOpk] = useState<OvulationTest | null>(null);
|
||||
const [mucus, setMucus] = useState<CervicalMucus | null>(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<string | null>(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 (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("cycle.sheet.title")}
|
||||
description={t("cycle.sheet.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("cycle.sheet.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={busy}>
|
||||
{logDay.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{logDay.isPending ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* One-tap period start — only meaningful for today. */}
|
||||
{date === today ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleStartPeriod}
|
||||
disabled={busy}
|
||||
style={{ borderColor: FLOW_HUE }}
|
||||
>
|
||||
{startPeriod.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
|
||||
)}
|
||||
{t("cycle.startedPeriod")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* Flow */}
|
||||
<Field label={t("cycle.sheet.flow")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FLOW_LEVELS.map((f) => (
|
||||
<Chip
|
||||
key={f}
|
||||
active={flow === f}
|
||||
onClick={() => setFlow((cur) => (cur === f ? null : f))}
|
||||
>
|
||||
{t(`cycle.flow.${f}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Symptoms */}
|
||||
<Field label={t("cycle.sheet.symptoms")}>
|
||||
<div className="space-y-3">
|
||||
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
|
||||
<div key={cat.key} className="space-y-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<cat.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(cat.labelKey)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cat.symptoms.map((s) => (
|
||||
<Chip
|
||||
key={s.key}
|
||||
active={symptoms.has(s.key)}
|
||||
onClick={() => toggleSymptom(s.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={30}
|
||||
max={45}
|
||||
value={bbt}
|
||||
onChange={(e) => setBbt(e.target.value)}
|
||||
placeholder={t("cycle.sheet.temperaturePlaceholder")}
|
||||
className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
|
||||
aria-label={t("cycle.sheet.temperature")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Ovulation test */}
|
||||
<Field label={t("cycle.sheet.ovulationTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{OPK_VALUES.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={opk === v}
|
||||
onClick={() => setOpk((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.ovulationTest.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Cervical mucus */}
|
||||
<Field label={t("cycle.sheet.mucus")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MUCUS_VALUES.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={mucus === v}
|
||||
onClick={() => setMucus((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.mucus.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
|
||||
{t("cycle.sheet.intercourse")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-intercourse"
|
||||
checked={intercourse}
|
||||
onCheckedChange={setIntercourse}
|
||||
/>
|
||||
</div>
|
||||
{intercourse ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="cycle-protected"
|
||||
className="text-muted-foreground text-sm font-normal"
|
||||
>
|
||||
{t("cycle.sheet.protected")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-protected"
|
||||
checked={protectedSex}
|
||||
onCheckedChange={setProtectedSex}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Note */}
|
||||
<Field label={t("cycle.sheet.note")}>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder={t("cycle.sheet.notePlaceholder")}
|
||||
aria-label={t("cycle.sheet.note")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{logDay.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.sheet.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.15.0 — cycle-phase hue tokens.
|
||||
*
|
||||
* Each phase maps to a dedicated, per-theme CSS custom property defined in
|
||||
* `globals.css` (Dracula dark + Alucard light), so the hue is hand-tuned for
|
||||
* contrast in both themes — exactly the wellness-ring pattern. The phase
|
||||
* semantic is never colour-only: every surface that uses a hue also renders
|
||||
* the phase word + an aria-label.
|
||||
*/
|
||||
import type { CyclePhase } from "./types";
|
||||
|
||||
export const PHASE_HUE: Record<CyclePhase, string> = {
|
||||
MENSTRUAL: "var(--cycle-phase-menstrual)",
|
||||
FOLLICULAR: "var(--cycle-phase-follicular)",
|
||||
OVULATORY: "var(--cycle-phase-ovulatory)",
|
||||
LUTEAL: "var(--cycle-phase-luteal)",
|
||||
};
|
||||
|
||||
/** Flow-intensity tint for the calendar period dots (rose family). */
|
||||
export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
/** Fertile-window tint (follicular green family). */
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleDisclaimer } from "./cycle-disclaimer";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the predictions panel.
|
||||
*
|
||||
* Shows the next-period RANGE (a window, with a confidence pill), the fertile
|
||||
* window (already goal-gated server-side — the API nulls it unless TTC, so we
|
||||
* just render what is present), the "still learning" state for < 3 cycles or
|
||||
* raw-chart mode, the cycle-history stats, and the fixed non-medical
|
||||
* disclaimer. Never a single dated next-period claim — always the range.
|
||||
*/
|
||||
|
||||
function formatDate(d: string): string {
|
||||
// Render at noon UTC so the YYYY-MM-DD never rolls a day across tz.
|
||||
return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function confidenceTone(c: number): {
|
||||
labelKey: string;
|
||||
variant: "default" | "secondary" | "outline";
|
||||
} {
|
||||
if (c >= 0.66)
|
||||
return { labelKey: "cycle.predictions.confidenceHigh", variant: "default" };
|
||||
if (c >= 0.33)
|
||||
return {
|
||||
labelKey: "cycle.predictions.confidenceMedium",
|
||||
variant: "secondary",
|
||||
};
|
||||
return { labelKey: "cycle.predictions.confidenceLow", variant: "outline" };
|
||||
}
|
||||
|
||||
export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** Fallback disclaimer when no prediction carries one. */
|
||||
fallbackDisclaimer?: string;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
fallbackDisclaimer,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const disclaimer = prediction?.disclaimer ?? fallbackDisclaimer ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="text-primary h-4 w-4" aria-hidden="true" />
|
||||
{t("cycle.predictions.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rawChartMode ? (
|
||||
<Learning
|
||||
title={t("cycle.predictions.rawMode")}
|
||||
body={t("cycle.predictions.rawModeBody")}
|
||||
/>
|
||||
) : prediction == null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.predictions.none")}
|
||||
</p>
|
||||
) : prediction.stillLearning ? (
|
||||
<>
|
||||
<Learning
|
||||
title={t("cycle.predictions.stillLearning")}
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NextPeriod prediction={prediction} />
|
||||
{prediction.fertileWindowStart && prediction.fertileWindowEnd ? (
|
||||
<Row
|
||||
hue={FERTILE_HUE}
|
||||
label={t("cycle.predictions.fertileWindow")}
|
||||
value={`${formatDate(prediction.fertileWindowStart)} – ${formatDate(
|
||||
prediction.fertileWindowEnd,
|
||||
)}`}
|
||||
/>
|
||||
) : null}
|
||||
{prediction.predictedOvulation ? (
|
||||
<Row
|
||||
hue={OVULATION_HUE}
|
||||
label={t("cycle.predictions.ovulation")}
|
||||
value={formatDate(prediction.predictedOvulation)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<HistoryCard history={history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextPeriod({ prediction }: { prediction: CyclePrediction }) {
|
||||
const { t } = useTranslations();
|
||||
const tone = confidenceTone(prediction.confidence);
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Row
|
||||
hue={FLOW_HUE}
|
||||
label={t("cycle.predictions.nextPeriod")}
|
||||
value={t("cycle.predictions.nextPeriodWindow", {
|
||||
low: formatDate(prediction.nextPeriodStartLow),
|
||||
high: formatDate(prediction.nextPeriodStartHigh),
|
||||
})}
|
||||
/>
|
||||
<Badge variant={tone.variant} className="shrink-0">
|
||||
{t(tone.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
hue,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
hue: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-foreground ml-auto text-sm font-medium tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Learning({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="bg-muted/40 rounded-md p-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryCard({
|
||||
history,
|
||||
}: {
|
||||
history: CycleHistoryResponse | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const stats = history?.stats;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("cycle.history.title")}</CardTitle>
|
||||
{stats ? (
|
||||
<CardDescription>
|
||||
{t(`cycle.history.regularity${stats.regularity}`)}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!stats || stats.avgLengthDays == null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.history.none")}
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Stat
|
||||
label={t("cycle.history.avgLength")}
|
||||
value={t("cycle.history.days", { count: stats.avgLengthDays })}
|
||||
/>
|
||||
{stats.lengthVariabilityDays != null ? (
|
||||
<Stat
|
||||
label={t("cycle.history.variability")}
|
||||
value={t("cycle.history.daysPlusMinus", {
|
||||
count: stats.lengthVariabilityDays,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{stats.avgPeriodLengthDays != null ? (
|
||||
<Stat
|
||||
label={t("cycle.history.avgPeriodLength")}
|
||||
value={t("cycle.history.days", {
|
||||
count: stats.avgPeriodLengthDays,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground mt-0.5 text-lg font-semibold tabular-nums">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* v1.15.0 — the seeded cycle-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The catalog ships seeded-only in v1.15.0 (no per-user custom CRUD yet),
|
||||
* so the keys are fixed at the migration seed (0129). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the camelCase i18n leaf under `cycle.symptom.*`; the `icon` is the
|
||||
* Lucide name from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql` and
|
||||
* the `cycle.symptom.*` / `cycle.symptomCategory.*` i18n leaves.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
BatteryLow,
|
||||
Brain,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
Cookie,
|
||||
Drama,
|
||||
Droplet,
|
||||
Flame,
|
||||
Frown,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Snowflake,
|
||||
Soup,
|
||||
Toilet,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CycleSymptom {
|
||||
/** snake_case DB key — the value sent in `symptoms[].key`. */
|
||||
key: string;
|
||||
/** i18n leaf under `cycle.symptom.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface CycleSymptomCategory {
|
||||
key: string;
|
||||
/** i18n leaf under `cycle.symptomCategory.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
symptoms: CycleSymptom[];
|
||||
}
|
||||
|
||||
export const CYCLE_SYMPTOM_CATALOG: CycleSymptomCategory[] = [
|
||||
{
|
||||
key: "physical",
|
||||
labelKey: "cycle.symptomCategory.physical",
|
||||
icon: Activity,
|
||||
symptoms: [
|
||||
{ key: "cramps", labelKey: "cycle.symptom.cramps", icon: Zap },
|
||||
{ key: "headache", labelKey: "cycle.symptom.headache", icon: Brain },
|
||||
{ key: "bloating", labelKey: "cycle.symptom.bloating", icon: CircleDot },
|
||||
{ key: "acne", labelKey: "cycle.symptom.acne", icon: Droplet },
|
||||
{
|
||||
key: "breast_tenderness",
|
||||
labelKey: "cycle.symptom.breastTenderness",
|
||||
icon: HeartPulse,
|
||||
},
|
||||
{ key: "fatigue", labelKey: "cycle.symptom.fatigue", icon: BatteryLow },
|
||||
{
|
||||
key: "back_pain",
|
||||
labelKey: "cycle.symptom.backPain",
|
||||
icon: PersonStanding,
|
||||
},
|
||||
{ key: "insomnia", labelKey: "cycle.symptom.insomnia", icon: MoonStar },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "emotional",
|
||||
labelKey: "cycle.symptomCategory.emotional",
|
||||
icon: Heart,
|
||||
symptoms: [
|
||||
{ key: "libido_high", labelKey: "cycle.symptom.libidoHigh", icon: Flame },
|
||||
{
|
||||
key: "libido_low",
|
||||
labelKey: "cycle.symptom.libidoLow",
|
||||
icon: Snowflake,
|
||||
},
|
||||
{ key: "mood_swings", labelKey: "cycle.symptom.moodSwings", icon: Drama },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "digestive",
|
||||
labelKey: "cycle.symptomCategory.digestive",
|
||||
icon: Soup,
|
||||
symptoms: [
|
||||
{
|
||||
key: "food_cravings",
|
||||
labelKey: "cycle.symptom.foodCravings",
|
||||
icon: Cookie,
|
||||
},
|
||||
{ key: "nausea", labelKey: "cycle.symptom.nausea", icon: Frown },
|
||||
{ key: "diarrhea", labelKey: "cycle.symptom.diarrhea", icon: Toilet },
|
||||
{
|
||||
key: "constipation",
|
||||
labelKey: "cycle.symptom.constipation",
|
||||
icon: CircleSlash,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat lookup: symptom key → its i18n labelKey (for rendering dots/labels). */
|
||||
export const SYMPTOM_LABEL_BY_KEY: Record<string, string> = Object.fromEntries(
|
||||
CYCLE_SYMPTOM_CATALOG.flatMap((c) =>
|
||||
c.symptoms.map((s) => [s.key, s.labelKey]),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.15.0 — client-side mirrors of the `/api/cycle/*` response DTOs
|
||||
* (ios-contract §2). These match `src/lib/cycle/dto.ts` +
|
||||
* `src/lib/cycle/engine-adapter.ts` field-for-field so the read hooks can
|
||||
* type the unwrapped `(await res.json()).data`.
|
||||
*/
|
||||
|
||||
export type CyclePhase =
|
||||
| "MENSTRUAL"
|
||||
| "FOLLICULAR"
|
||||
| "OVULATORY"
|
||||
| "LUTEAL";
|
||||
|
||||
export type FlowLevel = "NONE" | "SPOTTING" | "LIGHT" | "MEDIUM" | "HEAVY";
|
||||
|
||||
export type OvulationTest =
|
||||
| "NEGATIVE"
|
||||
| "POSITIVE_LH_SURGE"
|
||||
| "ESTROGEN_SURGE"
|
||||
| "INDETERMINATE";
|
||||
|
||||
export type CervicalMucus =
|
||||
| "DRY"
|
||||
| "STICKY"
|
||||
| "CREAMY"
|
||||
| "WATERY"
|
||||
| "EGG_WHITE";
|
||||
|
||||
export type CycleGoal =
|
||||
| "GENERAL_HEALTH"
|
||||
| "AVOID_PREGNANCY"
|
||||
| "TRYING_TO_CONCEIVE"
|
||||
| "PERIMENOPAUSE"
|
||||
| "OFF";
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
isPredictedPeriod: boolean;
|
||||
isFertileWindow: boolean;
|
||||
isPredictedOvulation: boolean;
|
||||
isPeriodLogged: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface CyclePrediction {
|
||||
method: string;
|
||||
nextPeriodStart: string;
|
||||
nextPeriodStartLow: string;
|
||||
nextPeriodStartHigh: string;
|
||||
fertileWindowStart: string | null;
|
||||
fertileWindowEnd: string | null;
|
||||
predictedOvulation: string | null;
|
||||
confidence: number;
|
||||
cyclesObserved: number;
|
||||
stillLearning: boolean;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
profile: {
|
||||
goal: CycleGoal;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
cyclesObserved: number;
|
||||
};
|
||||
prediction: CyclePrediction | null;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
periodEndDate: string | null;
|
||||
lengthDays: number | null;
|
||||
ovulationDate: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
isPredicted: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CycleHistoryResponse {
|
||||
cycles: MenstrualCycleDTO[];
|
||||
stats: {
|
||||
avgLengthDays: number | null;
|
||||
lengthVariabilityDays: number | null;
|
||||
avgPeriodLengthDays: number | null;
|
||||
regularity: "REGULAR" | "IRREGULAR" | "LEARNING";
|
||||
};
|
||||
}
|
||||
|
||||
export interface CycleProfileDTO {
|
||||
goal: CycleGoal;
|
||||
cycleTrackingEnabled: boolean;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
typicalCycleLength: number | null;
|
||||
typicalPeriodLength: number | null;
|
||||
lutealPhaseLength: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A phase-correlation insight card the API surfaces (another wave fills these). */
|
||||
export interface CycleInsightCard {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
n?: number;
|
||||
effectSize?: number;
|
||||
qValue?: number;
|
||||
caveat?: string;
|
||||
}
|
||||
|
||||
/** The day-log capture payload (subset of the API input we send from web). */
|
||||
export interface CycleDayLogInput {
|
||||
date: string;
|
||||
flow?: FlowLevel;
|
||||
basalBodyTempC?: number;
|
||||
ovulationTest?: OvulationTest;
|
||||
cervicalMucus?: CervicalMucus;
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
symptoms?: { key: string }[];
|
||||
note?: string;
|
||||
loggedAt: string;
|
||||
source?: "MANUAL";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.0 — cycle read + write hooks.
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data` per the project envelope rule;
|
||||
* every key is factory-routed through `queryKeys.cycle*`. Writes invalidate
|
||||
* the whole `["cycle"]` prefix via `cycleDependentKeys` so the calendar,
|
||||
* wheel, predictions panel, and history repaint in lockstep after a quick
|
||||
* log.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
CalendarResponse,
|
||||
CycleDayLogInput,
|
||||
CycleHistoryResponse,
|
||||
CycleProfileDTO,
|
||||
} from "./types";
|
||||
|
||||
async function unwrap<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const json = await res.json();
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export function useCycleCalendar(from: string, to: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleCalendar(from, to),
|
||||
queryFn: () =>
|
||||
fetch(`/api/cycle/calendar?from=${from}&to=${to}`).then((r) =>
|
||||
unwrap<CalendarResponse>(r),
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleHistory(limit = 24) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleHistory(limit),
|
||||
queryFn: () =>
|
||||
fetch(`/api/cycle/cycles?limit=${limit}`).then((r) =>
|
||||
unwrap<CycleHistoryResponse>(r),
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleProfile() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleProfile(),
|
||||
queryFn: () =>
|
||||
fetch("/api/cycle/profile").then((r) => unwrap<CycleProfileDTO>(r)),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** A fresh idempotency key per write attempt (matches the iOS Outbox shape). */
|
||||
function idempotencyKey(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function useLogDay() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: CycleDayLogInput) => {
|
||||
const res = await fetch("/api/cycle/day-logs", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey(),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return unwrap<unknown>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartPeriod() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (date: string) => {
|
||||
const res = await fetch("/api/cycle/period", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "start",
|
||||
date,
|
||||
loggedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return unwrap<unknown>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CyclePrefsPatch {
|
||||
enabled?: boolean;
|
||||
goal?: CycleProfileDTO["goal"];
|
||||
rawChartMode?: boolean;
|
||||
predictionEnabled?: boolean;
|
||||
discreetNotifications?: boolean;
|
||||
sensitiveCategoryEncryption?: boolean;
|
||||
typicalCycleLength?: number | null;
|
||||
typicalPeriodLength?: number | null;
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
export function useUpdateCyclePrefs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: CyclePrefsPatch) => {
|
||||
const res = await fetch("/api/auth/me/cycle-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return unwrap<CycleProfileDTO>(res);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
// The enable flag + goal live on /api/auth/me too — repaint the nav gate.
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* v1.15.0 — derive the cycle-wheel state (day-of-cycle, current phase,
|
||||
* proportional phase spans) from the calendar read.
|
||||
*
|
||||
* The calendar read already labels each day with its `phase`; we walk
|
||||
* backward from today to the start of the current run of consecutive
|
||||
* non-null phase days to find the cycle's start, count the day-of-cycle, and
|
||||
* tally each phase's share of that run for the proportional ring segments.
|
||||
* Pure + deterministic so it matches the server engine's labels 1:1.
|
||||
*/
|
||||
import type { CalendarDay, CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface WheelState {
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
}
|
||||
|
||||
export function deriveWheelState(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
): WheelState {
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
|
||||
}
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) {
|
||||
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
|
||||
}
|
||||
|
||||
// Walk back to the start of the current MENSTRUAL-anchored run: the first
|
||||
// day of the most recent MENSTRUAL block at/before today with no gap.
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (d.phase === "MENSTRUAL" && (i === 0 || sorted[i - 1].phase !== "MENSTRUAL")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,23 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
// `<BottomNav>` reads `cycleTrackingEnabled` from `useAuth()` to gate the
|
||||
// cycle hub entry. Each test mutates `mockUserRef.value` before rendering.
|
||||
const mockUserRef = {
|
||||
value: { id: "u1", cycleTrackingEnabled: false } as {
|
||||
id: string;
|
||||
cycleTrackingEnabled: boolean;
|
||||
} | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUserRef.value,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BottomNav } from "../bottom-nav";
|
||||
|
||||
@@ -108,4 +125,20 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
/data-testid="bottom-nav-more"[^>]*aria-label="More"/.test(html);
|
||||
expect(hasLabelFirst || hasTestidFirst).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the cycle hub entry out of the closed-hub strip when the gate is off", () => {
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: false };
|
||||
const html = render();
|
||||
// Like Measurements / Mood, the cycle entry lives in the More hub
|
||||
// (rendered on open — exercised in e2e), never an always-visible strip.
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("renders without error when the cycle gate is on (entry rides the More hub)", () => {
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: true };
|
||||
// The closed-hub SSR markup carries no hub items either way; this guards
|
||||
// that gating on the flag never throws.
|
||||
expect(() => render()).not.toThrow();
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: false };
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const mockUserRef = {
|
||||
email: "marc@example.com",
|
||||
role: "USER" as "USER" | "ADMIN",
|
||||
avatarUrl: null,
|
||||
cycleTrackingEnabled: false,
|
||||
},
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -54,14 +55,16 @@ function render({
|
||||
pathname = "/",
|
||||
bugReportEnabled = true,
|
||||
role = "USER" as "USER" | "ADMIN",
|
||||
cycleTrackingEnabled = false,
|
||||
}: {
|
||||
pathname?: string;
|
||||
bugReportEnabled?: boolean;
|
||||
role?: "USER" | "ADMIN";
|
||||
cycleTrackingEnabled?: boolean;
|
||||
} = {}) {
|
||||
mockPathnameRef.value = pathname;
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
mockUserRef.value = { ...mockUserRef.value, role };
|
||||
mockUserRef.value = { ...mockUserRef.value, role, cycleTrackingEnabled };
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SidebarNav />
|
||||
@@ -84,6 +87,19 @@ describe("<SidebarNav> bug-report toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
|
||||
it("renders the Cycle entry when cycle tracking is enabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: true });
|
||||
expect(html).toContain('href="/cycle"');
|
||||
expect(html).toContain("Cycle");
|
||||
});
|
||||
|
||||
it("hides the Cycle entry when cycle tracking is disabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: false });
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
it("no longer renders the deprecated /targets entry", () => {
|
||||
// The Targets (Zielwerte) page is deprecated; target editing moved
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
Home,
|
||||
Lightbulb,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -70,12 +72,27 @@ const MORE_HUB: ReadonlyArray<NavLink> = [
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
];
|
||||
|
||||
// v1.15.0 — the cycle entry, spliced into the More hub only when the
|
||||
// account's `cycleTrackingEnabled` gate is true. Hidden for everyone else.
|
||||
const CYCLE_HUB_ITEM: NavLink = {
|
||||
href: "/cycle",
|
||||
tKey: "nav.cycle",
|
||||
icon: Droplets,
|
||||
};
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.15.0 — splice the cycle entry into the More hub after Mood when the
|
||||
// account's gate is on, so it never appears for accounts without it.
|
||||
const moreHub: ReadonlyArray<NavLink> = user?.cycleTrackingEnabled
|
||||
? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)]
|
||||
: MORE_HUB;
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
}
|
||||
@@ -83,7 +100,7 @@ export function BottomNav() {
|
||||
// The "More" entry is treated as active when any of its children
|
||||
// is the current page — gives the user a clue that they've drilled
|
||||
// into a sub-route via the hub.
|
||||
const moreActive = MORE_HUB.some((item) => isActiveLink(item.href));
|
||||
const moreActive = moreHub.some((item) => isActiveLink(item.href));
|
||||
|
||||
function renderPrimary(item: NavLink) {
|
||||
const isActive = isActiveLink(item.href);
|
||||
@@ -190,7 +207,7 @@ export function BottomNav() {
|
||||
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid grid-cols-2 gap-2 px-4 pb-4">
|
||||
{MORE_HUB.map((item) => {
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Bug,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Droplets,
|
||||
Home,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
@@ -80,6 +81,16 @@ const navItems = [
|
||||
},
|
||||
];
|
||||
|
||||
// v1.15.0 — the cycle nav entry, appended to the main list only when the
|
||||
// account's `cycleTrackingEnabled` gate is true (resolved on `/api/auth/me`).
|
||||
// Hidden by construction for accounts without the feature.
|
||||
const cycleNavItem = {
|
||||
href: "/cycle",
|
||||
tKey: "nav.cycle",
|
||||
icon: Droplets,
|
||||
tourId: "nav-cycle",
|
||||
} as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
@@ -249,6 +260,10 @@ export function SidebarNav() {
|
||||
// with no sub-item expansion in the global sidebar — `<AdminShell>`
|
||||
// renders its own per-section nav inside the page itself.
|
||||
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
// v1.15.0 — append the cycle entry only when the gate resolves true.
|
||||
const visibleNavItems = user?.cycleTrackingEnabled
|
||||
? [...navItems, cycleNavItem]
|
||||
: navItems;
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
@@ -350,7 +365,7 @@ export function SidebarNav() {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
|
||||
@@ -61,6 +61,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ export interface AuthUser {
|
||||
*/
|
||||
insurerIkNumber: string | null;
|
||||
insuranceNumber: string | null;
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
|
||||
* + the per-user opt-in. The cycle nav entry + page hide when false; every
|
||||
* `/api/cycle/*` route also enforces the gate. Coerced to `false` against a
|
||||
* stale /me payload (older server image without the field) in `fetchMe`.
|
||||
*/
|
||||
cycleTrackingEnabled: boolean;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
@@ -80,6 +87,9 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
// v1.7.0 — coerce against a stale /me payload (older server image
|
||||
// without the field) so the display defaults to metric.
|
||||
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
|
||||
// v1.15.0 — coerce against a stale /me payload so the cycle nav entry
|
||||
// stays hidden by default when the field is absent.
|
||||
cycleTrackingEnabled: data.cycleTrackingEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,21 @@ export const queryKeys = {
|
||||
*/
|
||||
insightsTrendSeries: (types: string) =>
|
||||
["trend-series", types] as const,
|
||||
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking surfaces. `cycle()` is the root prefix every
|
||||
* cycle write invalidates through (`cycleDependentKeys` below). The
|
||||
* calendar read is keyed by `(from, to)` so paging the month strip caches
|
||||
* each window independently; the history + profile reads each get their
|
||||
* own slot. A day-log / period write evicts the whole `["cycle"]` prefix
|
||||
* so the calendar, the wheel, the predictions panel, and the history
|
||||
* stats repaint in lockstep.
|
||||
*/
|
||||
cycle: () => ["cycle"] as const,
|
||||
cycleCalendar: (from: string, to: string) =>
|
||||
["cycle", "calendar", from, to] as const,
|
||||
cycleHistory: (limit: number) => ["cycle", "history", limit] as const,
|
||||
cycleProfile: () => ["cycle", "profile"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -573,6 +588,19 @@ export const medicationDependentKeys = [
|
||||
["compliance-chart-inline"] as const,
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when cycle data changes (a day-log capture, a period
|
||||
* boundary, a day-log delete). The `["cycle"]` prefix catches the calendar
|
||||
* windows, the history stats, and the profile read in one tick so the
|
||||
* calendar/wheel and predictions panel never read stale rows after a quick
|
||||
* log. `insightsRoot()` rides along because phase-correlation cards depend
|
||||
* on the same rows.
|
||||
*/
|
||||
export const cycleDependentKeys = [
|
||||
queryKeys.cycle(),
|
||||
queryKeys.insightsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
|
||||
Reference in new issue
Block a user