Files
HealthLog/src/components/cycle/cycle-settings.tsx
T
Marc-André Bombeck bed47387de fix(ui): show text-field focus as a neutral border, not a purple ring
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.

text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
2026-10-05 21:08:48 +02:00

379 lines
12 KiB
TypeScript

"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, SecondarySymptom } 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",
];
const SECONDARY_SYMPTOMS: SecondarySymptom[] = ["MUCUS", "CERVIX"];
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 [secondarySymptom, setSecondarySymptom] = useState<SecondarySymptom>(
profile.secondarySymptom,
);
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,
secondarySymptom,
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={10}
max={16}
/>
</div>
</div>
<Separator />
{/* Advanced — symptothermal secondary symptom. The default is mucus;
the choice never forces itself on a casual user (progressive
disclosure: it sits here in the advanced cycle settings). */}
<div className="space-y-2">
<Label htmlFor="cycle-secondary-symptom">
{t("cycle.settings.secondarySymptomLabel")}
</Label>
<NativeSelect
id="cycle-secondary-symptom"
value={secondarySymptom}
onChange={(e) =>
setSecondarySymptom(e.target.value as SecondarySymptom)
}
>
{SECONDARY_SYMPTOMS.map((s) => (
<option key={s} value={s}>
{t(`cycle.settings.secondarySymptom.${s}`)}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-sm">
{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>
<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 />
{/* Reminders — the per-channel toggles live on the notifications page;
point users there rather than duplicating the toggle state here. */}
<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
switch on the notifications page; here we just point a TTC user to
it, so the affordance never appears (and fertile language never
shows) for the other goals (the inclusive-framing rule). */}
{goal === "TRYING_TO_CONCEIVE" ? (
<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
Account surfaces (cycle rows ride the full-backup export and the
account-level delete), so the standalone affordances are not
duplicated here. */}
<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="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-sm">{description}</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
className="mt-0.5 shrink-0"
/>
</div>
);
}
/**
* 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,
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:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none"
/>
</div>
);
}