mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(settings): standardize settings and admin section framing
Introduce a shared SettingsSectionFrame (the settings twin of the admin renderer's SectionFrame) that emits the canonical visible heading + subheading, and route all 19 settings sections through it. Every settings page now shows a real h1 + subtitle in the reference Übersicht style instead of an invisible sr-only title; the section bodies drop their own headings and off-style intro paragraphs (the explanatory copy folds into the frame subtitle). - Route the admin About page through SectionFrame so it carries the same visible heading as every other admin section. - Rename the AI settings section from KI-Auswertung to KI-Anbieter across six locales: the page configures the provider and key, not an analysis. - Add a heading-block spacer to the settings and admin sidebars so the first menu item lines up with the top of the first card. - Remove the always-on core-domains card from Module: a tracking area that can't be turned off doesn't need listing. - Move the proactive-coach control out of Benachrichtigungen into Coach. - Move admin module availability out of the Coach section into its own page: it gates every module, not just the coach. - Give the Layout Insights editor a proper Insights heading. Adds settings.sections.<slug>.subtitle and the new admin keys across all six locales; removes the now-unused module core-domain strings.
This commit is contained in:
1 parent
ae3091df9e
commit
33d7d103da
42 files changed
+822
-802
No files matched your search
@@ -18,6 +18,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Blocks,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
@@ -39,6 +40,7 @@ import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
interface AdminSection {
|
||||
@@ -82,6 +84,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.coach.title",
|
||||
icon: Sparkles,
|
||||
},
|
||||
// v1.18.6 (W9) — server-wide module availability split out of Coach into
|
||||
// its own entry, sitting next to it.
|
||||
{
|
||||
slug: "module-availability",
|
||||
titleKey: "admin.section.module-availability.title",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
titleKey: "admin.section.feedback.title",
|
||||
@@ -276,6 +285,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
className="hidden md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
{/* v1.18.6 (W9) — align the first nav item with the top of the
|
||||
first card, matching the settings shell. */}
|
||||
<SectionNavHeadingSpacer />
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
|
||||
@@ -16,11 +16,15 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"integrations",
|
||||
// v1.18.1 — the former `ai-quality`, `assistant`, and `coach-feedback`
|
||||
// sections collapse into one "Coach" area. It holds the assistant
|
||||
// surface toggles, the server-key + module-availability operator
|
||||
// config, and both feedback-quality tables (Coach + Insights). Three
|
||||
// navigation entries for one coherent coach-quality concern read as
|
||||
// clutter; one card opens the full picture.
|
||||
// surface toggles, the server-key operator config, and both
|
||||
// feedback-quality tables (Coach + Insights). Three navigation entries
|
||||
// for one coherent coach-quality concern read as clutter; one card opens
|
||||
// the full picture.
|
||||
"coach",
|
||||
// v1.18.6 (W9) — server-wide module availability is its own section now
|
||||
// (was stacked under Coach). It gates every module, not just the coach,
|
||||
// so it reads better next to Coach than buried inside it.
|
||||
"module-availability",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
|
||||
@@ -31,12 +31,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { CoachSection } from "../coach-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
// v1.18.6 (W9) — the visible heading lives in the shared frame the route
|
||||
// wraps the section in; render through it so the heading id is present.
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<CoachSection />
|
||||
<SettingsSectionFrame slug="coach">
|
||||
<CoachSection />
|
||||
</SettingsSectionFrame>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
@@ -51,6 +56,9 @@ describe("<CoachSection> — SSR smoke", () => {
|
||||
// v1.18.1 (D7) — polarity flipped to activate/default-on.
|
||||
expect(html).toContain("Activate Coach");
|
||||
expect(html).toContain("Show the Coach button and drawer. On by default");
|
||||
// v1.18.6 (W9) — the proactive Coach nudge card moved here from
|
||||
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
// Raw key never leaks past i18n.
|
||||
expect(html).not.toContain("settings.sections.coach.");
|
||||
expect(html).not.toContain("settings.coach.activate.");
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { ExportSection } from "../export-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
@@ -54,7 +55,13 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
|
||||
describe("<ExportSection> — SSR smoke", () => {
|
||||
it("renders the section heading + description", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// v1.18.6 (W9) — the page heading comes from the shared frame the route
|
||||
// wraps the section in.
|
||||
const html = render(
|
||||
<SettingsSectionFrame slug="export">
|
||||
<ExportSection />
|
||||
</SettingsSectionFrame>,
|
||||
);
|
||||
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
|
||||
expect(html).toContain("Export & Import");
|
||||
// Raw key never leaks past i18n.
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { GesundheitsakteSection } from "../gesundheitsakte-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
@@ -38,8 +39,14 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<GesundheitsakteSection> — SSR smoke", () => {
|
||||
it("renders the section description and the health-record panel", () => {
|
||||
const html = render(<GesundheitsakteSection />);
|
||||
it("renders the section heading and the health-record panel", () => {
|
||||
// v1.18.6 (W9) — the visible heading (with the historic id) comes from
|
||||
// the shared frame the route wraps the section in.
|
||||
const html = render(
|
||||
<SettingsSectionFrame slug="gesundheitsakte">
|
||||
<GesundheitsakteSection />
|
||||
</SettingsSectionFrame>,
|
||||
);
|
||||
expect(html).toContain('id="settings-section-gesundheitsakte-title"');
|
||||
expect(html).toContain('data-testid="health-record-export-panel"');
|
||||
// Raw key never leaks past i18n.
|
||||
|
||||
@@ -65,12 +65,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { MedicationsSection } from "../medications-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
// v1.18.6 (W9) — the visible heading (with the historic id) comes from the
|
||||
// shared frame the route wraps the section in.
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MedicationsSection />
|
||||
<SettingsSectionFrame slug="medications">
|
||||
<MedicationsSection />
|
||||
</SettingsSectionFrame>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
CORE_DOMAIN_KEYS,
|
||||
moduleDelegatesTo,
|
||||
} from "@/lib/modules/registry";
|
||||
import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
|
||||
|
||||
// Capture the latest useMutation config + a shared mutate spy so we can
|
||||
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
|
||||
@@ -46,9 +42,9 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>(
|
||||
() => ({ user: buildUser({}), isAuthenticated: true }),
|
||||
);
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser({}), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
@@ -109,7 +105,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("<ModulesSection>", () => {
|
||||
it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => {
|
||||
it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => {
|
||||
// v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain
|
||||
// that can't be turned off doesn't need listing), so this section now
|
||||
// only renders the toggleable modules.
|
||||
const html = render();
|
||||
for (const key of MODULE_KEYS) {
|
||||
if (moduleDelegatesTo(key) !== undefined) {
|
||||
@@ -126,8 +125,16 @@ describe("<ModulesSection>", () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of CORE_DOMAIN_KEYS) {
|
||||
expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
|
||||
});
|
||||
|
||||
it("no longer renders the 'Always on' core-domains card", () => {
|
||||
// v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was
|
||||
// removed; the core domains are always active regardless.
|
||||
const html = render();
|
||||
for (const key of ["weight", "bloodPressure", "pulse"]) {
|
||||
expect(html, `core ${key} not rendered`).not.toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,18 +159,6 @@ describe("<ModulesSection>", () => {
|
||||
expect(moodTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the core domains locked (checked + disabled)", () => {
|
||||
const html = render();
|
||||
for (const key of CORE_DOMAIN_KEYS) {
|
||||
const tag = html.match(
|
||||
new RegExp(`<button[^>]*id="module-toggle-${key}"[^>]*>`),
|
||||
)?.[0];
|
||||
expect(tag, `core ${key}`).toBeDefined();
|
||||
expect(tag).toMatch(/data-state="checked"/);
|
||||
expect(tag).toMatch(/disabled/);
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCHes only the flipped key as a disabled-allowlist entry", async () => {
|
||||
render();
|
||||
expect(lastMutation?.mutationFn).toBeTypeOf("function");
|
||||
|
||||
@@ -131,12 +131,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { MoodSection } from "../mood-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
// v1.18.6 (W9) — the visible heading (with the historic id) comes from the
|
||||
// shared frame the route wraps the section in.
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MoodSection />
|
||||
<SettingsSectionFrame slug="mood">
|
||||
<MoodSection />
|
||||
</SettingsSectionFrame>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
* v1.18.0 (S4) — module-gated per-type visibility on the consolidated
|
||||
* "Benachrichtigungen" reminder-types home.
|
||||
*
|
||||
* The mood reminder shows only when the `mood` module is enabled; the Coach
|
||||
* nudge only when `coach` is enabled. Low-stock maps to the medications CORE
|
||||
* domain (always shown). The gate fails OPEN, so a `/me` payload without a
|
||||
* module map keeps every card visible.
|
||||
* The mood reminder shows only when the `mood` module is enabled. Low-stock
|
||||
* maps to the medications domain (shown unless explicitly disabled). The gate
|
||||
* fails OPEN, so a `/me` payload without a module map keeps every card
|
||||
* visible.
|
||||
*
|
||||
* v1.18.6 (W9) — the proactive Coach nudge card moved to Settings → Coach, so
|
||||
* it no longer appears on this screen.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -47,30 +50,23 @@ function render(modules?: Record<string, boolean>) {
|
||||
}
|
||||
|
||||
describe("<NotificationsSection> module-gated reminder types", () => {
|
||||
it("shows mood + coach cards when no module map (fails open)", () => {
|
||||
it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
|
||||
const html = render(undefined);
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
// v1.18.6 (W9) — the coach nudge moved to Settings → Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
|
||||
it("hides the mood card when the mood module is disabled", () => {
|
||||
const html = render({ mood: false });
|
||||
expect(html).not.toContain('id="mood-reminder"');
|
||||
// Coach + low-stock are unaffected.
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
// Low-stock is unaffected.
|
||||
expect(html).toContain('id="low-stock"');
|
||||
});
|
||||
|
||||
it("hides the coach card when the coach module is disabled", () => {
|
||||
const html = render({ coach: false });
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
});
|
||||
|
||||
it("always shows low-stock (medications is a CORE domain)", () => {
|
||||
const html = render({ mood: false, coach: false });
|
||||
it("always shows low-stock unless medications is disabled", () => {
|
||||
const html = render({ mood: false });
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).not.toContain('id="mood-reminder"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
@@ -82,9 +78,9 @@ describe("<NotificationsSection> module-gated reminder types", () => {
|
||||
const html = render({});
|
||||
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
|
||||
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
|
||||
// The three reminder-type cards remain.
|
||||
// The remaining reminder-type cards (no coach nudge here anymore).
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,11 @@
|
||||
* v1.4.16 phase B6 — settings i18n parity.
|
||||
*
|
||||
* Every slug declared in `SETTINGS_SECTION_SLUGS` MUST have a
|
||||
* `settings.sections.<slug>.title` AND a `settings.sections.<slug>.description`
|
||||
* resolved in both English and German. The audit doc
|
||||
* (`docs/audit/v1416-settings-audit.md`) calls this out as the canonical
|
||||
* shape — the sidebar, the page `<h1>`, and the page subtitle all read
|
||||
* from these two keys, so a missing key paints a raw key in production.
|
||||
* `settings.sections.<slug>.title` AND a `settings.sections.<slug>.subtitle`
|
||||
* resolved in both English and German. The sidebar reads `.title`; the shared
|
||||
* `<SettingsSectionFrame>` reads `.title` + `.subtitle` for the visible page
|
||||
* heading + subheading (v1.18.6 W9), so a missing key paints a raw key in
|
||||
* production.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -46,43 +46,39 @@ describe("settings sections — i18n parity (B6)", () => {
|
||||
expect(deValue?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it(`every locale resolves settings.sections.${slug}.description`, () => {
|
||||
it(`every locale resolves settings.sections.${slug}.subtitle`, () => {
|
||||
const enValue = lookup(en as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"description",
|
||||
"subtitle",
|
||||
]);
|
||||
const deValue = lookup(de as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"description",
|
||||
"subtitle",
|
||||
]);
|
||||
expect(enValue, `EN missing description for ${slug}`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect(enValue, `EN missing subtitle for ${slug}`).toBeTypeOf("string");
|
||||
expect(enValue?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(deValue, `DE missing description for ${slug}`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect(deValue, `DE missing subtitle for ${slug}`).toBeTypeOf("string");
|
||||
expect(deValue?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("EN and DE description for every slug are different (catches accidental copy-paste)", () => {
|
||||
it("EN and DE subtitle for every slug are different (catches accidental copy-paste)", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
const enValue = lookup(en as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"description",
|
||||
"subtitle",
|
||||
]);
|
||||
const deValue = lookup(de as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"description",
|
||||
"subtitle",
|
||||
]);
|
||||
// It's legitimate for some titles to coincide between locales when
|
||||
// the proper noun is the same (e.g. "Dashboard", "API & Tokens").
|
||||
@@ -90,7 +86,7 @@ describe("settings sections — i18n parity (B6)", () => {
|
||||
// EN value into the DE bag is a bug.
|
||||
expect(
|
||||
deValue,
|
||||
`DE description for ${slug} is identical to EN — needs translation`,
|
||||
`DE subtitle for ${slug} is identical to EN — needs translation`,
|
||||
).not.toBe(enValue);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,6 +87,8 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({
|
||||
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
|
||||
}));
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import type { SettingsSectionSlug } from "../section-slugs";
|
||||
import { AboutSection } from "../about-section";
|
||||
import { AccountSection } from "../account-section";
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
@@ -108,10 +110,25 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.6 (W9) — the visible page heading + subtitle moved out of the section
|
||||
// bodies into the shared `<SettingsSectionFrame>` that the route wraps every
|
||||
// section in. The heading-level smoke checks render the body THROUGH the frame
|
||||
// (mirroring production) so they assert what the user actually sees.
|
||||
function renderFramed(
|
||||
slug: SettingsSectionSlug,
|
||||
node: React.ReactElement,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return render(
|
||||
<SettingsSectionFrame slug={slug}>{node}</SettingsSectionFrame>,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
describe("settings sections — SSR smoke", () => {
|
||||
it("<AccountSection> renders the Account heading and Profile card", () => {
|
||||
const html = render(<AccountSection />);
|
||||
// Section title resolves via the i18n provider.
|
||||
const html = renderFramed("account", <AccountSection />);
|
||||
// Section title resolves via the frame + i18n provider.
|
||||
expect(html).toContain("Account");
|
||||
// Profile card heading is also painted.
|
||||
expect(html).toContain("Profile");
|
||||
@@ -136,7 +153,7 @@ describe("settings sections — SSR smoke", () => {
|
||||
});
|
||||
|
||||
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
|
||||
const html = render(<AboutSection />);
|
||||
const html = renderFramed("about", <AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// v1.4.36 W4f — the dedicated "Updates" card with its manual
|
||||
// "Check now" button is gone. The 24 h auto-check still runs and
|
||||
@@ -158,34 +175,33 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("Replay the tour");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the AI Insights heading", () => {
|
||||
const html = render(<AiSection />);
|
||||
// v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the
|
||||
// surface is explicit (was the bare "Insights"). The section H1 carries
|
||||
// the only heading.
|
||||
expect(html).toContain("AI Insights");
|
||||
it("<AiSection> renders the AI provider heading", () => {
|
||||
const html = renderFramed("ai", <AiSection />);
|
||||
// v1.18.6 (W9) — section renamed to "AI provider"; the visible heading
|
||||
// now comes from the shared frame, which keeps the historic id.
|
||||
expect(html).toContain("AI provider");
|
||||
expect(html).toContain("settings-section-ai-title");
|
||||
});
|
||||
|
||||
it("<IntegrationsSection> renders Withings card title", () => {
|
||||
const html = render(<IntegrationsSection />);
|
||||
const html = renderFramed("integrations", <IntegrationsSection />);
|
||||
expect(html).toContain("Integrations");
|
||||
expect(html).toContain("Withings");
|
||||
});
|
||||
|
||||
it("<NotificationsSection> renders the single reminder-types home", () => {
|
||||
const html = render(<NotificationsSection />);
|
||||
// v1.18.1 (D0/D5) — the screen is lean: sr-only h1 only, no section blurb,
|
||||
// no channels/inbox cross-links, and no embedded Vorsorge editor.
|
||||
const html = renderFramed("notifications", <NotificationsSection />);
|
||||
// v1.18.6 (W9) — the visible heading comes from the frame; the proactive
|
||||
// Coach nudge moved to Settings → Coach, so it no longer renders here.
|
||||
expect(html).toContain("settings-section-notifications-title");
|
||||
expect(html).not.toContain("Notification channels");
|
||||
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
|
||||
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
|
||||
// The mood + coach cards fail OPEN (mock user carries no module map),
|
||||
// so all three reminder-type cards render.
|
||||
// The mood card fails OPEN (mock user carries no module map), so both
|
||||
// remaining reminder-type cards render. The coach-nudge card is gone.
|
||||
expect(html).toContain('id="mood-reminder"');
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
expect(html).toContain('id="low-stock"');
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
// No raw i18n key leaks past the provider.
|
||||
expect(html).not.toContain("settings.sections.notifications.");
|
||||
});
|
||||
@@ -194,32 +210,31 @@ describe("settings sections — SSR smoke", () => {
|
||||
// v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved
|
||||
// out into their own settings section under `/settings/thresholds`,
|
||||
// so the dashboard panel is now layout-only. Verifying the heading
|
||||
// says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the
|
||||
// layout slot still mounts.
|
||||
const html = render(<DashboardSection />);
|
||||
// (supplied by the frame) says "Dashboard" and the layout slot mounts.
|
||||
const html = renderFramed("dashboard", <DashboardSection />);
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain('data-testid="dashboard-layout"');
|
||||
expect(html).not.toContain('data-testid="thresholds"');
|
||||
});
|
||||
|
||||
it("<ApiSection> renders endpoints + tokens cards", () => {
|
||||
const html = render(<ApiSection />);
|
||||
const html = renderFramed("api", <ApiSection />);
|
||||
expect(html).toContain("API & Tokens");
|
||||
});
|
||||
|
||||
it("<AdvancedSection> renders the danger-zone card only (export moved to /settings/export)", () => {
|
||||
const html = render(<AdvancedSection />);
|
||||
const html = renderFramed("advanced", <AdvancedSection />);
|
||||
// v1.4.16 phase B7: every export path moved out into the dedicated
|
||||
// `<ExportSection>` so what remains is the irreversible delete-all
|
||||
// path. The section heading + dangerZone surface are the only
|
||||
// primary surfaces here now.
|
||||
// path. The heading (supplied by the frame) + dangerZone surface are the
|
||||
// only primary surfaces here now.
|
||||
expect(html).toContain("Advanced");
|
||||
expect(html).not.toContain("settings.export");
|
||||
expect(html).not.toContain("doctorReport");
|
||||
});
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
const html = renderFramed("about", <AboutSection />, "de");
|
||||
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
|
||||
// the surviving German copy is the section title + the Sources
|
||||
// & docs heading + the version line.
|
||||
@@ -235,7 +250,7 @@ describe("settings sections — SSR smoke", () => {
|
||||
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
|
||||
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
|
||||
// renders the target-range editor only.
|
||||
const html = render(<ThresholdsSection />);
|
||||
const html = renderFramed("thresholds", <ThresholdsSection />);
|
||||
// v1.8.7.1: section renamed to "Targets".
|
||||
expect(html).toContain("Targets");
|
||||
expect(html).toContain('data-testid="thresholds-editor"');
|
||||
@@ -244,7 +259,7 @@ describe("settings sections — SSR smoke", () => {
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> resolves the German title", () => {
|
||||
const html = render(<ThresholdsSection />, "de");
|
||||
const html = renderFramed("thresholds", <ThresholdsSection />, "de");
|
||||
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
|
||||
expect(html).toContain("Zielwerte");
|
||||
expect(html).toContain('data-testid="thresholds-editor"');
|
||||
|
||||
@@ -319,9 +319,9 @@ describe("<SettingsShell>", () => {
|
||||
expect(html).toContain('href="/settings/mood"');
|
||||
// Targets keeps its German nav entry ("Zielwerte").
|
||||
expect(html).toContain("Zielwerte");
|
||||
// v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in
|
||||
// German (the "KI" prefix makes the AI nature explicit).
|
||||
expect(html).toContain("KI-Auswertungen");
|
||||
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
|
||||
// page is about the provider / BYOK, not an "Auswertung".
|
||||
expect(html).toContain("KI-Anbieter");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
|
||||
@@ -181,17 +181,12 @@ export function AboutSection() {
|
||||
}, [version?.version]);
|
||||
/* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the section
|
||||
// frame that wraps this component: `<SettingsSectionFrame>` on the settings
|
||||
// route and the admin renderer's `SectionFrame` on `/admin/about`. The body
|
||||
// is the version / license / sources cards.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-about-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-about-title" className="sr-only">
|
||||
{t("settings.sections.about.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Identity card — version + license inline. The v1.4.2 layout
|
||||
stacked the license under the version inside its own boxed Badge,
|
||||
which read as a separate field even though the two values belong
|
||||
@@ -321,6 +316,6 @@ export function AboutSection() {
|
||||
button were collectively never the point of the surface —
|
||||
the only signal users ever cared about was "is a newer
|
||||
release out?", and the badge answers that with no clicks. */}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -367,20 +367,10 @@ export function AccountSection() {
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the profile cards.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-account-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — the explanatory section blurb was dropped so every
|
||||
settings section starts at the same vertical height; the sr-only
|
||||
heading stays for the accessibility tree. */}
|
||||
<header>
|
||||
<h1 id="settings-section-account-title" className="sr-only">
|
||||
{t("settings.sections.account.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Profile card */}
|
||||
{/* Profile photo card */}
|
||||
<AvatarSection />
|
||||
@@ -760,7 +750,7 @@ export function AccountSection() {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,26 +51,16 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
* dialog.
|
||||
*/
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-advanced-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-advanced-title" className="sr-only">
|
||||
{t("settings.sections.advanced.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
|
||||
<TourReplayCard />
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,8 @@
|
||||
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
@@ -35,19 +33,8 @@ export function AiSection() {
|
||||
const mounted = useMounted();
|
||||
const authed = mounted && isAuthenticated;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-ai-title" className="sr-only">
|
||||
{t("settings.sections.ai.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
|
||||
{/* v1.18.1 (D8) — the "About me" context moved to Settings → Account
|
||||
(under Profil, before Zyklus-Tracking). */}
|
||||
</section>
|
||||
);
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the single provider
|
||||
// card. (The "About me" context moved to Settings → Account in v1.18.1 D8.)
|
||||
return <AiInsightsCard isAuthenticated={authed} />;
|
||||
}
|
||||
@@ -37,19 +37,13 @@ interface ApiTokenInfo {
|
||||
}
|
||||
|
||||
export function ApiSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the API cards.
|
||||
return (
|
||||
<section aria-labelledby="settings-section-api-title" className="space-y-6">
|
||||
<header>
|
||||
<h1 id="settings-section-api-title" className="sr-only">
|
||||
{t("settings.sections.api.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<ApiTokensCard />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,30 +8,13 @@
|
||||
* delivery provider — the same family as a connected device — and now reads as
|
||||
* a first-class settings home rather than a tab hidden behind Integrationen.
|
||||
*
|
||||
* The content is the existing `<NotificationChannelsPanel>`; this wrapper only
|
||||
* owns the section frame (sr-only heading scaffold matching every other
|
||||
* settings section).
|
||||
* The content is the existing `<NotificationChannelsPanel>`. v1.18.6 (W9) —
|
||||
* the visible heading + subtitle now come from the shared
|
||||
* `<SettingsSectionFrame>` in the route; this wrapper is the panel only.
|
||||
*/
|
||||
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function ChannelsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-channels-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-channels-title" className="sr-only">
|
||||
{t("settings.sections.channels.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
);
|
||||
return <NotificationChannelsPanel />;
|
||||
}
|
||||
@@ -10,25 +10,31 @@
|
||||
* 1. Disable Coach — the per-user opt-out (kept first so a user who
|
||||
* wants the surface gone finds it immediately).
|
||||
* 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`).
|
||||
* 3. Coach memory — durable-fact review + forget controls.
|
||||
* 3. Proactive nudge — the daily-nudge opt-out + per-group frequency
|
||||
* (moved here from Settings → Benachrichtigungen in
|
||||
* v1.18.6: it is a Coach setting, not a generic
|
||||
* notification).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + memory cards
|
||||
* keep their own content gate on `!user.disableCoach` (mirrored from the
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge + memory
|
||||
* cards keep their own content gate on `!user.disableCoach` (mirrored from the
|
||||
* old AI section): hiding the Coach hides its tuning + memory controls too.
|
||||
*
|
||||
* The "about me" context lives in the AI section, not here — the daily
|
||||
* briefing reads it too, so it is not a Coach-only setting.
|
||||
*
|
||||
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
* `<SettingsSectionFrame>` in the route; this body is the Coach cards only.
|
||||
*/
|
||||
|
||||
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function CoachSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// Mirror the AI section's hydration-safe gating: the auth query can
|
||||
// resolve before this boundary hydrates, so gate the child props on
|
||||
@@ -42,22 +48,20 @@ export function CoachSection() {
|
||||
const coachEnabled = !mounted || !user?.disableCoach;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-coach-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-coach-title" className="sr-only">
|
||||
{t("settings.sections.coach.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<DisableCoachCard isAuthenticated={authed} />
|
||||
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
|
||||
|
||||
{/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6).
|
||||
Keeps the `#coach-nudge` anchor so existing deep links resolve. */}
|
||||
{coachEnabled && (
|
||||
<div id="coach-nudge" className="scroll-mt-28">
|
||||
<CoachNudgeCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
* `<SettingsSectionFrame>`, and the "← back to hub" link rides the frame's
|
||||
* `topSlot`. The body is the layout customizer only.
|
||||
*/
|
||||
export function DashboardSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-dashboard-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-dashboard-title" className="sr-only">
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
</header>
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
</section>
|
||||
);
|
||||
return <DashboardLayoutSection id="dashboard-layout" />;
|
||||
}
|
||||
@@ -70,17 +70,11 @@ async function downloadFromUrl(url: string, filename: string): Promise<void> {
|
||||
|
||||
export function ExportSection() {
|
||||
const { t } = useTranslations();
|
||||
// v1.18.6 (W9) — the visible page heading + subtitle now come from the
|
||||
// shared `<SettingsSectionFrame>` in the route; the inner `<h2>` keeps its
|
||||
// own "other options" subsection label.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-export-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-export-title" className="sr-only">
|
||||
{t("settings.sections.export.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* v1.18.0 (S5) — the full health-record export moved to its own
|
||||
top-level "Gesundheitsakte" section. This page keeps the generic
|
||||
CSV/JSON data-out paths and the import surface. */}
|
||||
@@ -111,7 +105,7 @@ export function ExportSection() {
|
||||
`export.zip` and the generic JSON paths. The export routes had
|
||||
backends with no UI until now. */}
|
||||
<ImportPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,32 +18,10 @@
|
||||
*/
|
||||
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export function GesundheitsakteSection() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-gesundheitsakte-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header className="flex items-center justify-end">
|
||||
<h1
|
||||
id="settings-section-gesundheitsakte-title"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("settings.sections.gesundheitsakte.title")}
|
||||
</h1>
|
||||
{/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
|
||||
real spotlight anchor (the section h1 is sr-only). */}
|
||||
<span data-tour-id="export-hero">
|
||||
<ModuleTourTrigger stopId="export" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<HealthRecordExportPanel />
|
||||
</section>
|
||||
);
|
||||
// v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
|
||||
// trigger now live in the shared `<SettingsSectionFrame>` in the route; the
|
||||
// body is the health-record export panel only.
|
||||
return <HealthRecordExportPanel />;
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
|
||||
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
|
||||
@@ -15,28 +13,16 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*
|
||||
* Both write through the same `/api/insights/layout` contract the tab strip
|
||||
* and the overview share, so a save here repaints both surfaces in lockstep.
|
||||
*
|
||||
* v1.18.6 (W9) — the visible "Insights" heading + subtitle now come from the
|
||||
* shared `<SettingsSectionFrame>`, and the "← back to hub" link rides the
|
||||
* frame's `topSlot`. The body is the two arrange cards only.
|
||||
*/
|
||||
export function InsightsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-insights-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-insights-title" className="sr-only">
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
|
||||
<InsightsPillOrderSection id="insights-pill-order" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,6 @@
|
||||
*/
|
||||
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export {
|
||||
parseOAuthOutcome,
|
||||
@@ -25,27 +23,8 @@ export {
|
||||
} from "@/components/settings/integrations/connections-panel";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-integrations-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header className="flex items-center justify-end">
|
||||
<h1 id="settings-section-integrations-title" className="sr-only">
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
{/* v1.18.6 — guided-tour re-entry for the integrations module. The
|
||||
visible affordance also gives the spotlight a real anchor (the
|
||||
section h1 is sr-only). */}
|
||||
<span data-tour-id="integrations-hero">
|
||||
<ModuleTourTrigger stopId="integrations" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ConnectionsPanel />
|
||||
</section>
|
||||
);
|
||||
// v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
|
||||
// trigger now live in the shared `<SettingsSectionFrame>` in the route; the
|
||||
// body is the connections panel only.
|
||||
return <ConnectionsPanel />;
|
||||
}
|
||||
@@ -53,47 +53,38 @@ const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
|
||||
export function LayoutSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the hub link list.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-layout-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-layout-title" className="sr-only">
|
||||
{t("settings.sections.layout.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{LAYOUT_LINKS.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(link.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<ul className="space-y-3">
|
||||
{LAYOUT_LINKS.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(link.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -73,17 +73,10 @@ export function MedicationsSection() {
|
||||
),
|
||||
].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active }));
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the medication cards.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-medications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-medications-title" className="sr-only">
|
||||
{t("settings.sections.medications.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* View preference — cards vs table. The shared header toggle
|
||||
writes optimistically, so there is no Save button here. */}
|
||||
<div
|
||||
@@ -130,6 +123,6 @@ export function MedicationsSection() {
|
||||
they moved here from the account profile so all medication-specific
|
||||
preferences live in one place. */}
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,9 @@ import {
|
||||
Droplet,
|
||||
Dumbbell,
|
||||
FileText,
|
||||
HeartPulse,
|
||||
Lock,
|
||||
MessageCircleHeart,
|
||||
Moon,
|
||||
Pill,
|
||||
Scale,
|
||||
Smile,
|
||||
Sparkles,
|
||||
TestTube,
|
||||
@@ -52,10 +49,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
CORE_DOMAIN_KEYS,
|
||||
MODULE_REGISTRY,
|
||||
moduleDelegatesTo,
|
||||
type CoreDomainKey,
|
||||
type ModuleKey,
|
||||
} from "@/lib/modules/registry";
|
||||
|
||||
@@ -77,13 +72,6 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
doctorReport: FileText,
|
||||
};
|
||||
|
||||
/** Neutral Lucide glyph per core (always-on) domain. */
|
||||
const CORE_ICONS: Record<CoreDomainKey, LucideIcon> = {
|
||||
weight: Scale,
|
||||
bloodPressure: HeartPulse,
|
||||
pulse: Activity,
|
||||
};
|
||||
|
||||
export function ModulesSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -113,103 +101,65 @@ export function ModulesSection() {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` (the former muted-`<p>` intro folded into the
|
||||
// frame's standard subtitle). The "Module immer aktiv" core-domains card
|
||||
// was removed: a domain that can't be turned off doesn't need to be listed.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-modules-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.6 — restore a short intro line so a new user understands
|
||||
that this page drives the nav, tiles and assessments they see.
|
||||
(Dropped in v1.18.1 D0 for top alignment; the orientation cost
|
||||
outweighed the alignment.) */}
|
||||
<header className="space-y-1">
|
||||
<h1 id="settings-section-modules-title" className="sr-only">
|
||||
{t("settings.sections.modules.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.sections.modules.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Blocks}
|
||||
title={t("settings.sections.modules.toggleable.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.modules.toggleable.description")}
|
||||
</p>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
// Default-on: a module is enabled unless explicitly `false`.
|
||||
const enabled = modules[key] !== false;
|
||||
// Delegated modules (cycle/coach) are owned elsewhere — never a
|
||||
// live toggle here; deep-link to their real control instead.
|
||||
const delegated = moduleDelegatesTo(key) !== undefined;
|
||||
// Operator turned this module off server-wide: no per-user
|
||||
// toggle can re-enable it, so show a read-only note. Delegated
|
||||
// rows already deep-link, so this only matters for owned keys.
|
||||
const operatorDisabled =
|
||||
!delegated && moduleAvailability[key] === false;
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
moduleKey={key}
|
||||
icon={MODULE_ICONS[key]}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
enabled={enabled}
|
||||
pending={toggle.isPending}
|
||||
managedAt={
|
||||
delegated && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t(def.managedAt.labelKey),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
manageLinkLabel={
|
||||
delegated && def.managedAt
|
||||
? t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
operatorDisabled={operatorDisabled}
|
||||
operatorNote={t("settings.sections.modules.operatorDisabled")}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core domains — always on, read-only */}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Lock}
|
||||
title={t("settings.sections.modules.core.title")}
|
||||
description={t("settings.sections.modules.core.description")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
{CORE_DOMAIN_KEYS.map((key) => (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Blocks}
|
||||
title={t("settings.sections.modules.toggleable.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.modules.toggleable.description")}
|
||||
</p>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
// Default-on: a module is enabled unless explicitly `false`.
|
||||
const enabled = modules[key] !== false;
|
||||
// Delegated modules (cycle/coach) are owned elsewhere — never a
|
||||
// live toggle here; deep-link to their real control instead.
|
||||
const delegated = moduleDelegatesTo(key) !== undefined;
|
||||
// Operator turned this module off server-wide: no per-user
|
||||
// toggle can re-enable it, so show a read-only note. Delegated
|
||||
// rows already deep-link, so this only matters for owned keys.
|
||||
const operatorDisabled =
|
||||
!delegated && moduleAvailability[key] === false;
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
moduleKey={key}
|
||||
icon={CORE_ICONS[key]}
|
||||
label={t(`modules.${key}.label`)}
|
||||
description={t(`modules.${key}.description`)}
|
||||
enabled
|
||||
locked
|
||||
lockedNote={t("settings.sections.modules.core.note")}
|
||||
icon={MODULE_ICONS[key]}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
enabled={enabled}
|
||||
pending={toggle.isPending}
|
||||
managedAt={
|
||||
delegated && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t(def.managedAt.labelKey),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
manageLinkLabel={
|
||||
delegated && def.managedAt
|
||||
? t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
operatorDisabled={operatorDisabled}
|
||||
operatorNote={t("settings.sections.modules.operatorDisabled")}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,17 +40,10 @@ export function MoodSection() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the mood-tag cards.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-mood-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-mood-title" className="sr-only">
|
||||
{t("settings.sections.mood.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Groups — picker group order + own groups. */}
|
||||
<div
|
||||
id="mood-groups"
|
||||
@@ -87,6 +80,6 @@ export function MoodSection() {
|
||||
<ArchivedTagsCard catalog={catalog} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
|
||||
@@ -17,44 +15,31 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
*
|
||||
* Each reminder-type card is shown ONLY when its module is enabled, read from
|
||||
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
|
||||
* Insights pills gate off). Mood → `mood`, Coach nudge → `coach`, low-stock
|
||||
* runway → `medications` (a toggleable fail-open module since D3, so a missing
|
||||
* key reads as enabled and a stale `/me` payload never blanks a card).
|
||||
* Insights pills gate off). Mood → `mood`, low-stock runway → `medications`
|
||||
* (a toggleable fail-open module since D3, so a missing key reads as enabled
|
||||
* and a stale `/me` payload never blanks a card).
|
||||
*
|
||||
* v1.18.1 (D5) — the page is intentionally lean: the section blurb, the
|
||||
* channels / inbox cross-links, and the embedded Vorsorge (preventive-care)
|
||||
* editor were all removed. Vorsorge has its own `/vorsorge` page, so embedding
|
||||
* it here was a duplicate. Delivery CHANNELS (Telegram / ntfy / Web Push /
|
||||
* Webhook / Email) live under Settings → Integrations → Channels.
|
||||
*
|
||||
* v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
* `<SettingsSectionFrame>`. The proactive-Coach nudge card moved to Settings →
|
||||
* Coach (it is a Coach setting, not a generic notification), so this screen
|
||||
* carries the mood + low-stock reminder cards only.
|
||||
*/
|
||||
export function NotificationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
// v1.18.0 (S4) — module-gated per-type visibility. Fail OPEN (`!== false`)
|
||||
// so a stale `/me` payload without the module map keeps every card visible.
|
||||
const moodEnabled = user?.modules?.mood !== false;
|
||||
const coachEnabled = user?.modules?.coach !== false;
|
||||
const medsEnabled = user?.modules?.medications !== false;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-notifications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0/D5) — the section blurb and the channels / inbox cross-
|
||||
links were dropped: the header now starts at the same height as every
|
||||
other section and the page carries only the reminder-type cards. */}
|
||||
<header>
|
||||
<h1 id="settings-section-notifications-title" className="sr-only">
|
||||
{t("settings.sections.notifications.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* v1.18.1 (D5) — the Vorsorge (preventive-care) block was removed; it
|
||||
has its own /vorsorge page, so embedding the editor here was a
|
||||
duplicate. This screen keeps the three reminder-type cards below. */}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
@@ -69,13 +54,6 @@ export function NotificationsSection() {
|
||||
<LowStockCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Proactive Coach nudge — only when the coach module is enabled. */}
|
||||
{coachEnabled ? (
|
||||
<div id="coach-nudge" className="scroll-mt-28">
|
||||
<CoachNudgeCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `<SectionNavHeadingSpacer>` — invisible spacer that reserves exactly the
|
||||
* height of a section's heading block (h1 + subtitle) plus the `space-y-6`
|
||||
* gap that separates the heading from the first card.
|
||||
*
|
||||
* v1.18.6 (W9) — Marc's symmetry ask: the left settings / admin menu's first
|
||||
* item must line up with the TOP OF THE FIRST CARD, not with the page
|
||||
* heading. The page heading lives in the content column above the first card,
|
||||
* so the sidebar needs to start one heading-block lower. Replicating the
|
||||
* exact heading markup (same `text-2xl` h1 + `text-sm` subtitle) as a hidden,
|
||||
* `aria-hidden` block keeps the offset pixel-accurate across locales and
|
||||
* line-wrapping — a hard-coded `pt-[…]` would drift the moment a title or
|
||||
* subtitle wraps to a second line.
|
||||
*
|
||||
* It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip
|
||||
* strip has no heading-to-align-with.
|
||||
*/
|
||||
export function SectionNavHeadingSpacer() {
|
||||
return (
|
||||
<div aria-hidden="true" className="invisible mb-6 select-none">
|
||||
<h1 className="text-2xl font-bold tracking-tight"> </h1>
|
||||
<p className="text-muted-foreground text-sm"> </p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SettingsSectionFrame>` — the canonical page-frame for every Settings
|
||||
* section. The settings twin of the admin renderer's `SectionFrame`
|
||||
* (`src/app/admin/[section]/renderer.tsx`): it emits the one visible
|
||||
* heading + subheading every page must show, in the reference style used by
|
||||
* the Admin Konsole → Übersicht landing.
|
||||
*
|
||||
* Before this primitive every settings section hand-rolled its own
|
||||
* `sr-only` `<h1>`, so no settings page showed a visible heading and the
|
||||
* left-menu-to-first-card alignment drifted page to page. Routing all 19
|
||||
* sections through one frame fixes both: a constant-height heading block
|
||||
* above the first card, and a real visible `h1 + subtitle`.
|
||||
*
|
||||
* <SettingsSectionFrame slug="account">
|
||||
* {cards}
|
||||
* </SettingsSectionFrame>
|
||||
*
|
||||
* Title comes from `settings.sections.<slug>.title`; subtitle from
|
||||
* `settings.sections.<slug>.subtitle`. The `<h1>` keeps the historic
|
||||
* `settings-section-<slug>-title` id so the section's own `aria-labelledby`
|
||||
* linkage — and the spotlight-tour anchors that reference it — stay intact.
|
||||
*
|
||||
* `topSlot` renders ABOVE the heading (used by the Layout-hub child
|
||||
* editors for their "← back to hub" link, which must sit above the title).
|
||||
* `headingAccessory` renders inline at the top-right of the heading row
|
||||
* (used by Gesundheitsakte for its guided-tour re-entry trigger).
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "./section-slugs";
|
||||
|
||||
export interface SettingsSectionFrameProps {
|
||||
slug: SettingsSectionSlug;
|
||||
/** Optional content rendered above the heading (e.g. a back-to-hub link). */
|
||||
topSlot?: React.ReactNode;
|
||||
/** Optional content rendered inline at the top-right of the heading row
|
||||
* (e.g. a guided-tour re-entry trigger). */
|
||||
headingAccessory?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsSectionFrame({
|
||||
slug,
|
||||
topSlot,
|
||||
headingAccessory,
|
||||
children,
|
||||
}: SettingsSectionFrameProps) {
|
||||
const { t } = useTranslations();
|
||||
const titleId = `settings-section-${slug}-title`;
|
||||
|
||||
return (
|
||||
<section aria-labelledby={titleId} className="space-y-6">
|
||||
{topSlot ? <div>{topSlot}</div> : null}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
|
||||
{t(`settings.sections.${slug}.title`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`settings.sections.${slug}.subtitle`)}
|
||||
</p>
|
||||
</div>
|
||||
{headingAccessory ? (
|
||||
<div className="shrink-0">{headingAccessory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "./section-slugs";
|
||||
import { SectionNavHeadingSpacer } from "./section-nav-heading-spacer";
|
||||
|
||||
// Re-export so existing call-sites importing from this module keep working.
|
||||
// New server-side call-sites (e.g. `generateStaticParams()`) should import
|
||||
@@ -259,8 +260,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
// no `moduleGate` (global / CORE) are always shown.
|
||||
const modules = user?.modules;
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
!section.moduleGate || modules?.[section.moduleGate] !== false,
|
||||
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
|
||||
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
|
||||
@@ -358,6 +358,10 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
className="hidden md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
{/* v1.18.6 (W9) — push the first nav item down to the top of the
|
||||
first card (level with the page heading's following gap), so
|
||||
the menu reads symmetric with the content column. */}
|
||||
<SectionNavHeadingSpacer />
|
||||
<ul className="space-y-1">
|
||||
{visibleSections.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
|
||||
@@ -278,31 +278,13 @@ export function SourcesSection() {
|
||||
return Object.keys(priority.deviceTypePriority).length;
|
||||
}, [priority]);
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` (the off-style muted intro folded into the
|
||||
// standard subtitle). The quiet cross-link back to Integrationen — where
|
||||
// the sources this ladder ranks are added / removed — stays inside the
|
||||
// card, below the ladder.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-sources-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0/D4) — standalone left-side entry again. The section
|
||||
blurb is dropped for consistent top alignment; the cross-link back
|
||||
to Integrationen (where the sources this ladder ranks are added /
|
||||
removed) stays as a quiet pointer. */}
|
||||
<header>
|
||||
<h1 id="settings-section-sources-title" className="sr-only">
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.sources.integrationsHint")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="sources-integrations-cross-link"
|
||||
>
|
||||
{t("settings.sections.sources.integrationsHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Layers}
|
||||
@@ -578,8 +560,22 @@ export function SourcesSection() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
|
||||
sources this ladder ranks are connected / removed. Folded into
|
||||
the card so the page top stays the standard heading + subtitle. */}
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.sections.sources.integrationsHint")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="sources-integrations-cross-link"
|
||||
>
|
||||
{t("settings.sections.sources.integrationsHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`
|
||||
@@ -16,7 +15,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* flows, so each concern is self-contained on its own page.
|
||||
*
|
||||
* Visual structure on this page:
|
||||
* 1. Section header (title + description).
|
||||
* 1. Section heading + subtitle — supplied by the shared
|
||||
* `<SettingsSectionFrame>` in the route (v1.18.6 W9).
|
||||
* 2. `<ThresholdsEditorSection>` — per-metric range inputs. The editor
|
||||
* card keeps its own affordance row (icon, reset-all button).
|
||||
*
|
||||
@@ -31,21 +31,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* the names stop clashing.
|
||||
*/
|
||||
export function ThresholdsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-thresholds-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header>
|
||||
<h1 id="settings-section-thresholds-title" className="sr-only">
|
||||
{t("settings.sections.thresholds.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Per-metric threshold ranges. */}
|
||||
<ThresholdsEditorSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
// Per-metric threshold ranges; the heading is supplied by the route frame.
|
||||
return <ThresholdsEditorSection id="thresholds" />;
|
||||
}
|
||||
Reference in new issue
Block a user