refactor(settings): thin the Account tree down to the profile

Two cards on Settings → Account were asking questions that already have a
home somewhere else.

- cycle tracking is a module, and the Modules hub carries the switch that
  drives the same `cycleTrackingEnabled` column. The Account card was a
  second door to one decision, so it goes, and the "manage in Account"
  deep-link beside the module switch goes with it. That link pointed at a
  `#cycle-tracking` anchor no element ever rendered, so it never landed
  anywhere in the first place
- changing a password belongs with the second factors and the passkeys.
  The card moves to Settings → Security and leads that page. It renders
  before the factor status resolves: it is now the only way to change a
  password and must not depend on a read it does not need

Account keeps the profile, the photo, the identity fields and the AI
context. The `settings.cycleTracking.*` strings leave all six bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 15:17:05 +02:00
1 parent 0b09eb6b1e
commit 13b4a0be0b
13 files changed
+349 -240

No files matched your search

-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
},
"genderOther": "Divers",
"cycleTracking": {
"title": "Zyklus-Tracking",
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.",
"enable": "Zyklus-Tracking aktivieren",
"manage": "Zyklus-Tracking öffnen",
"error": "Zyklus-Tracking konnte nicht aktualisiert werden.",
"detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered."
},
"genderOther": "Other",
"cycleTracking": {
"title": "Cycle tracking",
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights.",
"enable": "Enable cycle tracking",
"manage": "Open cycle tracking",
"error": "Couldn't update cycle tracking.",
"detail": "On by default for accounts set to female; available to everyone."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
},
"genderOther": "Otro",
"cycleTracking": {
"title": "Seguimiento del ciclo",
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.",
"enable": "Activar el seguimiento del ciclo",
"manage": "Abrir seguimiento del ciclo",
"error": "No se pudo actualizar el seguimiento del ciclo.",
"detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré."
},
"genderOther": "Autre",
"cycleTracking": {
"title": "Suivi du cycle",
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.",
"enable": "Activer le suivi du cycle",
"manage": "Ouvrir le suivi du cycle",
"error": "Impossible de mettre à jour le suivi du cycle.",
"detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato."
},
"genderOther": "Altro",
"cycleTracking": {
"title": "Monitoraggio del ciclo",
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.",
"enable": "Attiva il monitoraggio del ciclo",
"manage": "Apri il monitoraggio del ciclo",
"error": "Impossibile aggiornare il monitoraggio del ciclo.",
"detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
-8
View File
@@ -6037,14 +6037,6 @@
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać."
},
"genderOther": "Inna",
"cycleTracking": {
"title": "Śledzenie cyklu",
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.",
"enable": "Włącz śledzenie cyklu",
"manage": "Otwórz śledzenie cyklu",
"error": "Nie udało się zaktualizować śledzenia cyklu.",
"detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
@@ -163,11 +163,20 @@ describe("<ModulesSection>", () => {
}
});
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
it("keeps a manage deep-link beside the delegated coach switch", () => {
const html = render();
// Coach → Coach settings; cycle → the Account cycle-tracking card.
// Coach → Coach settings. Coach is the only delegated module with more
// to configure than on/off (cadence, memory) at a settings address.
expect(html).toContain('href="/settings/coach"');
expect(html).toContain('href="/settings/account#cycle-tracking"');
});
it("offers no Account deep-link beside the cycle switch", () => {
// Cycle used to carry a second enable surface in Settings → Account,
// and this link pointed at it. The switch here drives the same column,
// so the Account card went and the link with it — a deep-link to an
// anchor nothing renders is a door into a wall.
const html = render();
expect(html).not.toContain("/settings/account#cycle-tracking");
});
it("no longer renders the 'Always on' core-domains card", () => {
@@ -0,0 +1,108 @@
/**
* Where the change-password card lives.
*
* It used to sit at the bottom of Settings → Account, under the profile form.
* Changing a password is the same kind of decision as enrolling a second
* factor, so it moved to Settings → Security. Two things have to hold for
* that move to be real rather than a duplicate:
*
* 1. Security mounts it — and mounts it BEFORE the factor status resolves,
* because it is now the only way to change a password and must not
* depend on a query it does not need.
* 2. Account does not, and no second copy of the dialog exists anywhere.
*
* The second claim is checked against the source tree rather than a render,
* because a render of `<AccountSection>` proves the card is absent from the
* one branch the test happened to reach. Sweeping the files proves there is
* exactly one place in the app that can paint it.
*/
import { describe, it, expect, vi } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: true }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: null,
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { PasswordCard } from "../password-card";
import { SecuritySection } from "../security-section";
const SLOT = 'data-slot="settings-password-card"';
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
const ROOT = process.cwd();
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
describe("change-password card placement", () => {
it("paints its slot", () => {
// Positive control for the two assertions below — without it, a renamed
// slot would make every "is it here / is it gone" check read green.
expect(render(<PasswordCard />)).toContain(SLOT);
});
it("is mounted by Settings → Security before the factor status lands", () => {
// The mocked query never resolves, which is the case that matters: the
// MFA read is still in flight (or failing) and the password card is
// reachable regardless.
const html = render(<SecuritySection />);
expect(html).toContain(SLOT);
});
it("has exactly one home in the tree", () => {
const owners = walk(join(ROOT, "src"))
.filter((file) => readFileSync(file, "utf8").includes("PasswordCard"))
.map((file) => file.slice(ROOT.length + 1))
.sort();
expect(owners).toEqual([
"src/components/settings/password-card.tsx",
"src/components/settings/security-section/index.tsx",
]);
});
it("left no password form behind in the Account section", () => {
const account = readFileSync(
join(ROOT, "src/components/settings/account-section/index.tsx"),
"utf8",
);
for (const marker of [
"settings.passwordReset",
"settings.changePassword",
"/api/auth/password",
"current-password",
]) {
expect(account, `Account still carries ${marker}`).not.toContain(marker);
}
});
});
@@ -2,11 +2,10 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Loader2, Save, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
@@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { HeightFieldControl } from "@/components/profile/height-field-control";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
@@ -94,17 +91,6 @@ export function AccountSection() {
"success" | "warning" | "error" | null
>(null);
// Password change dialog state.
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMsg, setPasswordMsg] = useState<StatusMessage | null>(null);
const [passwordMsgType, setPasswordMsgType] = useState<
"success" | "error" | null
>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
@@ -298,52 +284,6 @@ export function AccountSection() {
setSaving(false);
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordSaving(true);
setPasswordMsg(null);
setPasswordMsgType(null);
if (newPassword !== confirmPassword) {
setPasswordMsg({ key: "settings.passwordMismatch" });
setPasswordMsgType("error");
setPasswordSaving(false);
return;
}
try {
const res = await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const json = await res.json();
if (!res.ok) {
setPasswordMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setPasswordMsgType("error");
return;
}
setPasswordMsg({ key: "settings.passwordUpdated" });
setPasswordMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setPasswordMsg({ key: "common.networkError" });
setPasswordMsgType("error");
} finally {
setPasswordSaving(false);
}
}
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -584,32 +524,12 @@ export function AccountSection() {
provider-configuration screen. */}
<AboutMeSection isAuthenticated={isAuthenticated} />
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
lets any account opt in (or opt out) before the gated /cycle page is
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Cycle tracking is a module, and the Modules hub carries its switch.
The second enable surface that used to sit here asked the same
question in a second place. */}
{/* Password card. The action is this card's only one, so it sits in
the card's action row like every other primary. It stays full-width
under `sm` — the German "Passwort ändern" is long enough that a
right-hugging button used to break through the card's right border
on a narrow phone. */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Changing the password is a security control, so the card lives in
Settings → Security beside the second factors and the passkeys. */}
{/* v1.25.7 — active-session management + the security-activity feed
live only under Settings → Data & Privacy now; the duplicate cards
@@ -619,85 +539,6 @@ export function AccountSection() {
Erweitert. It is a maintenance / reset action, not a profile or
security control, so it sits beside Research Mode + the danger zone. */}
<ResponsiveSheet
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordMsg(null);
setPasswordMsgType(null);
}
}}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-success"
: "text-destructive"
}`}
>
{statusText(passwordMsg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={passwordSaving}
>
{passwordSaving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={profileDismissal.discardDialogOpen}
onConfirm={profileDismissal.confirmDiscard}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { Loader2, Save, Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
statusText,
type StatusMessage,
} from "@/components/settings/account-section/account-section-utils";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Change-password card.
*
* It used to sit at the bottom of Settings → Account, under the profile form
* and the identity fields. Changing a password is not a profile edit — it is
* the same decision as enrolling a second factor or adding a passkey, and
* those live in Settings → Security. The card moved to sit with them so
* "how I get into this account" reads as one place.
*
* The status hint stores the i18n KEY, not the translated string, so a locale
* switch re-renders it in the new language — the settings-wide convention
* `statusText` exists for.
*/
export function PasswordCard() {
const { t } = useTranslations();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
if (newPassword !== confirmPassword) {
setMsg({ key: "settings.passwordMismatch" });
setMsgType("error");
setSaving(false);
return;
}
try {
const res = await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const json = await res.json();
if (!res.ok) {
setMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setMsgType("error");
return;
}
setMsg({ key: "settings.passwordUpdated" });
setMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setMsg({ key: "common.networkError" });
setMsgType("error");
} finally {
setSaving(false);
}
}
return (
<>
{/* The action is this card's only one, so it sits in the card's action
row like every other primary. It stays full-width under `sm` — the
German "Passwort ändern" is long enough that a right-hugging button
used to break through the card's right border on a narrow phone. */}
<SettingsCard data-slot="settings-password-card">
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setMsg(null);
setMsgType(null);
}
}}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{msg && (
<p
role="alert"
className={`text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(msg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={saving}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
</>
);
}
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { PasswordCard } from "@/components/settings/password-card";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
@@ -45,28 +46,35 @@ export function SecuritySection() {
passkeys != null &&
passkeys.length === 0;
if (isLoading || !status) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
{/* The password leads, and it renders before the factor status has
landed — it depends on nothing this page fetches. It is also the
only way to change a password now that the Account card is gone, so
a `/api/auth/me/mfa` that never answers must not take it down with
the cards that do need it. */}
<PasswordCard />
<SecurityKeysCard keys={status.webauthn} />
{isLoading || !status ? (
<>
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</>
) : (
<>
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
</>
)}
</div>
);
}
+4 -2
View File
@@ -63,8 +63,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
// Mirror the ui Card shell so a semantic landmark card paints
// identically to the default <Card>-backed one.
className={cn(
"bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm",
flush ? SETTINGS_CARD_FLUSH_SHELL : "gap-4 p-4 md:gap-6 md:p-6",
"bg-card text-card-foreground rounded-xl border shadow-sm",
flush
? SETTINGS_CARD_FLUSH_SHELL
: "flex flex-col gap-4 p-4 md:gap-6 md:p-6",
className,
)}
{...props}
+3 -6
View File
@@ -203,12 +203,9 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
descriptionKey: "modules.cycle.description",
category: "tracking",
delegatesTo: "cycle",
// The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking
// card (goal, predictions, cycle lengths) lives in the Account section.
managedAt: {
href: "/settings/account#cycle-tracking",
labelKey: "settings.sections.account.title",
},
// No `managedAt`. The switch here drives `cycleTrackingEnabled`, and
// that is the whole of the decision; the goal, predictions and cycle
// lengths live on the /cycle page itself, not in Settings.
},
mood: {
key: "mood",