Merge w11b/cycleui into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:57:39 +02:00
commit 9cc84e2209
12 files changed
+100 -125

No files matched your search

+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Zyklus-Tracking",
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.",
"disclaimer": "Vorhersagen und Phasen sind beschreibende Schätzungen aus deinen erfassten Daten – keine medizinische Beratung und keine Verhütungsmethode. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Die Genauigkeit steigt, je mehr Zyklen du erfasst. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
"enable": "Zyklus-Tracking aktivieren",
"manage": "Zyklus-Tracking öffnen",
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
@@ -6276,6 +6277,7 @@
"rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.",
"secondarySymptomLabel": "Zweites Fruchtbarkeitszeichen",
"secondarySymptomDescription": "Das zweite Zeichen, das für die symptothermale Doppelkontrolle mit deiner Temperatur kombiniert wird. Die meisten beobachten den Zervixschleim; wechsle zur Gebärmutterhals-Beobachtung, wenn du das bevorzugst.",
"secondarySymptomCitation": "Mehr zur Methode (Studie)",
"secondarySymptom": {
"MUCUS": "Zervixschleim",
"CERVIX": "Gebärmutterhals"
+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Cycle tracking",
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.",
"disclaimer": "Predictions and phases are descriptive estimates from your logged data — not medical advice and not a contraceptive method. They never indicate that it is safe to have unprotected sex. Accuracy improves as you log more cycles. For contraception or any medical decision, consult a healthcare professional.",
"enable": "Enable cycle tracking",
"manage": "Open cycle tracking",
"error": "Couldn't update cycle tracking."
@@ -6281,6 +6282,7 @@
"rawChartModeDescription": "Show only your logged data with no predictions or interpretation.",
"secondarySymptomLabel": "Secondary fertility sign",
"secondarySymptomDescription": "The second sign paired with your temperature for the symptothermal double-check. Most people track cervical mucus; switch to cervix observation if you prefer.",
"secondarySymptomCitation": "Read about the method (study)",
"secondarySymptom": {
"MUCUS": "Cervical mucus",
"CERVIX": "Cervix observation"
+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Seguimiento del ciclo",
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.",
"disclaimer": "Las predicciones y las fases son estimaciones descriptivas a partir de tus datos registrados, no son consejo médico ni un método anticonceptivo. Nunca indican que sea seguro tener relaciones sin protección. La precisión mejora a medida que registras más ciclos. Para la anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
"enable": "Activar el seguimiento del ciclo",
"manage": "Abrir seguimiento del ciclo",
"error": "No se pudo actualizar el seguimiento del ciclo."
@@ -6276,6 +6277,7 @@
"rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.",
"secondarySymptomLabel": "Segundo signo de fertilidad",
"secondarySymptomDescription": "El segundo signo que se combina con tu temperatura para la doble comprobación sintotérmica. La mayoría observa el moco cervical; cambia a la observación del cuello uterino si lo prefieres.",
"secondarySymptomCitation": "Más sobre el método (estudio)",
"secondarySymptom": {
"MUCUS": "Moco cervical",
"CERVIX": "Cuello uterino"
+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Suivi du cycle",
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.",
"disclaimer": "Les prévisions et les phases sont des estimations descriptives issues de tes données enregistrées : ce ne sont ni un avis médical ni une méthode de contraception. Elles n'indiquent jamais qu'un rapport non protégé est sans risque. La précision s'améliore à mesure que tu enregistres plus de cycles. Pour la contraception ou toute décision médicale, consulte un professionnel de santé.",
"enable": "Activer le suivi du cycle",
"manage": "Ouvrir le suivi du cycle",
"error": "Impossible de mettre à jour le suivi du cycle."
@@ -6276,6 +6277,7 @@
"rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.",
"secondarySymptomLabel": "Deuxième signe de fertilité",
"secondarySymptomDescription": "Le deuxième signe associé à ta température pour la double vérification sympto-thermique. La plupart des personnes observent la glaire cervicale ; passe à l'observation du col de l'utérus si tu préfères.",
"secondarySymptomCitation": "En savoir plus sur la méthode (étude)",
"secondarySymptom": {
"MUCUS": "Glaire cervicale",
"CERVIX": "Col de l'utérus"
+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Monitoraggio del ciclo",
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.",
"disclaimer": "Le previsioni e le fasi sono stime descrittive basate sui dati che registri: non sono un parere medico né un metodo contraccettivo. Non indicano mai che sia sicuro avere rapporti non protetti. La precisione migliora man mano che registri più cicli. Per la contraccezione o qualsiasi decisione medica, rivolgiti a un professionista sanitario.",
"enable": "Attiva il monitoraggio del ciclo",
"manage": "Apri il monitoraggio del ciclo",
"error": "Impossibile aggiornare il monitoraggio del ciclo."
@@ -6276,6 +6277,7 @@
"rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.",
"secondarySymptomLabel": "Secondo segno di fertilità",
"secondarySymptomDescription": "Il secondo segno abbinato alla tua temperatura per il doppio controllo sintotermico. La maggior parte osserva il muco cervicale; passa all'osservazione della cervice se preferisci.",
"secondarySymptomCitation": "Scopri di più sul metodo (studio)",
"secondarySymptom": {
"MUCUS": "Muco cervicale",
"CERVIX": "Cervice"
+2
View File
@@ -4708,6 +4708,7 @@
"cycleTracking": {
"title": "Śledzenie cyklu",
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.",
"disclaimer": "Prognozy i fazy to opisowe szacunki na podstawie zarejestrowanych danych — nie są poradą medyczną ani metodą antykoncepcji. Nigdy nie wskazują, że współżycie bez zabezpieczenia jest bezpieczne. Dokładność rośnie wraz z liczbą zarejestrowanych cykli. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
"enable": "Włącz śledzenie cyklu",
"manage": "Otwórz śledzenie cyklu",
"error": "Nie udało się zaktualizować śledzenia cyklu."
@@ -6276,6 +6277,7 @@
"rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.",
"secondarySymptomLabel": "Drugi objaw płodności",
"secondarySymptomDescription": "Drugi objaw łączony z temperaturą w podwójnej kontroli objawowo-termicznej. Większość obserwuje śluz szyjkowy; przełącz na obserwację szyjki macicy, jeśli wolisz.",
"secondarySymptomCitation": "Więcej o metodzie (badanie)",
"secondarySymptom": {
"MUCUS": "Śluz szyjkowy",
"CERVIX": "Szyjka macicy"
@@ -37,33 +37,19 @@ const emptyHistory: CycleHistoryResponse = {
};
describe("<PredictionsPanel>", () => {
it("renders the next-period range and shows the disclaimer under the AVOID_PREGNANCY goal", () => {
it("renders the next-period range without repeating the non-medical disclaimer", () => {
// The disclaimer now lives once at the cycle-tracking on-ramp, never beneath
// the prediction surfaces, so the panel must not render it for any goal.
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="AVOID_PREGNANCY"
/>,
);
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("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="GENERAL_HEALTH"
/>,
);
expect(html).not.toContain('data-slot="cycle-disclaimer"');
expect(html).not.toContain("Estimates only, not medical advice.");
// The range still renders.
// The range (not a single date) renders.
expect(html).toContain("–");
});
-32
View File
@@ -1,32 +0,0 @@
"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>
);
}
+57 -32
View File
@@ -194,7 +194,18 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
))}
</NativeSelect>
<p className="text-muted-foreground text-sm">
{t("cycle.settings.secondarySymptomDescription")}
{t("cycle.settings.secondarySymptomDescription")}{" "}
{/* Primary source for the symptothermal double-check method: the
German NFP prospective cohort (Frank-Herrmann et al., Human
Reproduction 2007). Plain anchor — no markdown renderer. */}
<a
href="https://pubmed.ncbi.nlm.nih.gov/17314078/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("cycle.settings.secondarySymptomCitation")}
</a>
</p>
</div>
@@ -220,21 +231,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
{/* Reminders — the per-channel toggles live on the notifications page;
point users there rather than duplicating the toggle state here. */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.reminders")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.remindersDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.reminders")}
description={t("cycle.settings.remindersDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
{/* Fertile-window reminder — surfaced ONLY under the conception goal.
The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON
@@ -242,21 +243,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
it, so the affordance never appears (and fertile language never
shows) for the other goals (the inclusive-framing rule). */}
{goal === "TRYING_TO_CONCEIVE" ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.fertileReminder")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.fertileReminderDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.fertileReminder")}
description={t("cycle.settings.fertileReminderDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
) : null}
{/* Cycle data export + delete live with the main Settings → Export and
@@ -306,7 +297,7 @@ function ToggleRow({
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<p className="text-muted-foreground text-xs">{description}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch
id={id}
@@ -318,6 +309,40 @@ function ToggleRow({
);
}
/**
* A "label + description → go to notifications" row. The long fertile-window
* label collided with its button when both shared one tight flex line; here the
* row stacks on the narrowest screens (button on its own line, full-width tap
* target) and reflows to a clean inline layout from `sm` up, where the text
* column can wrap freely while the button stays top-aligned and never shrinks.
*/
function LinkRow({
title,
description,
linkLabel,
}: {
title: string;
description: string;
linkLabel: string;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Button
variant="outline"
size="sm"
asChild
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
<Link href="/notifications">{linkLabel}</Link>
</Button>
</div>
);
}
function PriorField({
id,
label,
+8 -20
View File
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
@@ -118,22 +117,11 @@ export function CycleView() {
const loading = calendar.isLoading && !calendar.data;
const calendarError = calendar.isError && !calendar.data;
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
// AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger
// "not a contraceptive method" caveat. Prefer the server-resolved
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
// key for the no-prediction calendar tab (QA H-1).
const goal = calendar.data?.profile.goal;
// Honesty-gate inputs for the phase-education card (Clue precedent): the
// predictive phase framing only shows when prediction is on, not in
// raw-chart mode, and at least three cycles are observed.
const calProfile = calendar.data?.profile;
const disclaimerText =
calendar.data?.prediction?.disclaimer ??
t(
goal === "AVOID_PREGNANCY"
? "cycle.disclaimer"
: "cycle.prediction.disclaimer",
);
return (
<div className="space-y-6">
@@ -162,8 +150,13 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* Wheel + compact phase card — grouped so they stick together. The
`lg:mt-15` (3.75rem) drops the column so the wheel tile's top edge
lines up with the calendar / predictions content rather than the
tab strip: TabsList `h-9` (2.25rem) + the Tabs root `gap-2` (0.5rem)
+ the content's `mt-4` (1rem). Below `lg` the column stacks first and
the offset collapses to zero. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6 lg:mt-15">
{/* Wheel — the signature ring on the premium wellness-tile surface.
`data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise`
— the keyframe selector is `[data-revealed="true"]
@@ -180,7 +173,7 @@ export function CycleView() {
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5",
play && "wellness-tile-rise",
)}
>
@@ -321,9 +314,6 @@ export function CycleView() {
)}
</CardContent>
</Card>
{!loading && !calendarError ? (
<CycleDisclaimer text={disclaimerText} />
) : null}
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
@@ -339,8 +329,6 @@ export function CycleView() {
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
goal={goal}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
+3 -22
View File
@@ -11,7 +11,6 @@ import {
} 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";
@@ -21,8 +20,9 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types";
* 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.
* raw-chart mode, and the cycle-history stats. Never a single dated next-period
* claim — always the range. The non-medical disclaimer is set once at the
* cycle-tracking on-ramp, not repeated beneath every prediction surface.
*/
function formatDate(d: string): string {
@@ -51,32 +51,15 @@ export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** Fallback disclaimer when no prediction carries one. */
fallbackDisclaimer?: string;
/** The active cycle goal — only AVOID_PREGNANCY surfaces the (deliberate)
* contraceptive-safety caveat here; every other goal drops the generic
* "predictions are estimates" line so it does not repeat under every graph. */
goal?: string;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
fallbackDisclaimer,
goal,
}: PredictionsPanelProps) {
const { t } = useTranslations();
// Only the AVOID_PREGNANCY goal shows a disclaimer here: it surfaces the
// fertile window, so the stronger "not a contraceptive method" caveat is a
// deliberate medical safety note. For all other goals the generic
// estimate-caveat is suppressed (it already lives once with the data).
const disclaimer =
goal === "AVOID_PREGNANCY"
? (prediction?.disclaimer ?? fallbackDisclaimer ?? "")
: "";
return (
<div className="space-y-4">
<Card>
@@ -129,8 +112,6 @@ export function PredictionsPanel({
) : null}
</>
)}
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
</CardContent>
</Card>
@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { CalendarHeart } from "lucide-react";
import { CalendarHeart, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -79,6 +79,21 @@ export function CycleTrackingCard({
className="mt-0.5 shrink-0"
/>
</div>
{/* The single, prominent non-medical disclaimer for the whole cycle
vertical. It lives here at the on-ramp — where expectations are
set — instead of repeating beneath every prediction surface. */}
<div
data-slot="cycle-tracking-disclaimer"
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
>
<Info
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-foreground/80 text-xs leading-relaxed">
{t("settings.cycleTracking.disclaimer")}
</p>
</div>
{enabled ? (
<Button
variant="outline"