feat(settings): split targets and sources, rename AI Insights section

Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 13:26:10 +02:00
1 parent 708d012d28
commit 78b76d2ac5
14 files changed
+122 -167

No files matched your search

+6 -4
View File
@@ -11,11 +11,12 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.4.34 IW-D — `sources` slug was retired (merged into `thresholds`).
// The combined "Targets & Sources" component lives in
// `thresholds-section.tsx`; `/settings/sources` is now a
// `permanentRedirect` handled by the dedicated route file.
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
// editor; `SourcesSection` (standalone mode) renders the source-priority
// ladders. Both are served by this dynamic route.
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import { SourcesSection } from "@/components/settings/sources-section";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
@@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
@@ -79,15 +79,6 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
// v1.4.34 IW-D — `<ThresholdsSection>` now also embeds
// `<SourcesSection mode="embedded">` so the combined "Targets &
// Sources" page reads as one shelf. The SourcesSection itself owns
// React Query and TanStack mutation wiring; stub it at the import
// boundary to keep the SSR smoke test focused on the wrapper.
vi.mock("@/components/settings/sources-section", () => ({
SourcesSection: () => <div data-testid="sources-section" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
@@ -169,12 +160,12 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Tour ansehen");
});
it("<AiSection> renders the Insights card", () => {
it("<AiSection> renders the AI Insights heading", () => {
const html = render(<AiSection />);
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
// per the Marc-Voice rule. The card title is replaced with a sparkles
// icon + status badges, and the section H1 carries the only heading.
expect(html).toContain("Insights");
// 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");
expect(html).toContain("settings-section-ai-title");
});
@@ -230,26 +221,26 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain(">Jetzt prüfen<");
});
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
it("<ThresholdsSection> renders the Targets heading and the threshold editor only", () => {
// v1.4.16 phase B6 contract: the route wrapper is named
// `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`), lives
// at `thresholds-section.tsx`, and embeds the inner editor under
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
// v1.4.34 IW-D — the wrapper now also embeds
// `<SourcesSection mode="embedded">` so the page is the merged
// "Targets & Sources" surface. Both editor mounts must be present.
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
// renders the target-range editor only.
const html = render(<ThresholdsSection />);
// v1.4.34 IW-D: section renamed to "Targets & Sources".
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1: section renamed to "Targets".
expect(html).toContain("Targets");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
it("<ThresholdsSection> resolves the German title", () => {
const html = render(<ThresholdsSection />, "de");
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
expect(html).toContain("Zielwerte");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
});
});
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the ten sections agreed for v1.4.34", () => {
it("declares the eleven sections", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -44,19 +44,17 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.4.16 phase B7 added the consolidated `export` section between
// `api` and `advanced` so every "give me my data out" path lives in
// one place.
// v1.4.25 W5e added `sources` between `thresholds` and `ai` so the
// per-metric-class source-priority surface lives next to the other
// analytics-shaping controls (thresholds → ranges; sources →
// canonical source per metric).
// v1.4.34 IW-D — `sources` slug was retired (merged into
// `thresholds`, now "Targets & Sources"). `/settings/sources` stays
// alive as a `permanentRedirect`. Section count: 10 (was 11).
// v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`. Section count: 11.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
@@ -164,11 +162,11 @@ describe("<SettingsShell>", () => {
// in the sidebar user-card dropdown. Route `/settings/about` is
// still alive for direct links.
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources
// under `/settings/thresholds`. The Sources nav entry must not
// appear; the route stays alive via permanentRedirect.
expect(html).not.toContain('href="/settings/sources"');
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1 — Targets and Sources are two separate nav entries again.
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
expect(html).toContain("Sources");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -184,21 +182,21 @@ describe("<SettingsShell>", () => {
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
// v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
// "Zielwerte & Quellen" (combined with the former Sources section).
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
expect(html).toContain("Auswertungen");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
expect(html).toContain("Quellen");
// 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");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources nav entry retired (merged into Targets &
// Sources).
expect(html).not.toContain('href="/settings/sources"');
// v1.8.7.1 — both Targets and Sources nav entries are present.
expect(html).toContain('href="/settings/sources"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
@@ -16,6 +16,7 @@ import {
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
+8 -8
View File
@@ -6,20 +6,20 @@
* graph.
*/
// v1.4.34 IW-D — `sources` slug was retired. The two former sections
// (`/settings/thresholds` and `/settings/sources`) merged into a single
// "Targets & Sources" page under `/settings/thresholds`. The historic
// `/settings/sources` URL stays alive as a `permanentRedirect` (see
// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS,
// docs) follow through unchanged. Per
// `.planning/research/v1434-r-2-carryover-scope.md` §7 and
// `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are again two
// separate settings pages, each its own left-nav entry and route. They were
// merged into one "Targets & Sources" page in v1.4.34 IW-D, but the two
// editors drive distinct mutation flows (`/api/user/thresholds` vs
// `/api/auth/me/source-priority`) and reading them as one page made the
// surface long; splitting them back keeps each concern self-contained.
// Both slugs are served by the dynamic `[section]` route below.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
+10 -8
View File
@@ -21,6 +21,7 @@ import {
Bell,
Download,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -74,14 +75,10 @@ interface SettingsSection {
* navigation. Direct links (the user-card dropdown, the public
* `/about` permalink) continue to resolve to the same component.
*
* v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled
* "Targets & Sources" / "Zielwerte & Quellen"). The combined section
* surfaces per-metric source priority alongside the threshold inputs
* on a single page. `/settings/sources` stays alive as a
* `permanentRedirect` so external bookmarks keep working; the slug
* was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic
* `[section]` route no longer serves it. Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7.
* v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
* separate sidebar entries again (merged into a single "Targets &
* Sources" page in v1.4.34 IW-D, split back here). Both are served by
* the dynamic `[section]` route.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -105,6 +102,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
+23 -43
View File
@@ -99,25 +99,15 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
};
/**
* v1.4.34 IW-D — section presentation mode.
* - `"standalone"` (default) renders the page-level header (h1 +
* subtitle) and keeps the section's own aria-labelledby wiring.
* - `"embedded"` drops the header because a parent surface
* (`<ThresholdsSection>` on `/settings/thresholds`) already names
* the page. The section becomes a `<div>` instead of a
* `<section>` so the assistive-tech reading order stays one
* combined surface.
* `<SourcesSection>` — route-level wrapper for `/settings/sources`
* (the "Sources" / "Quellen" page).
*
* `/settings/sources` no longer renders this component directly — it
* permanently redirects to `/settings/thresholds`, which uses the
* `embedded` mode. The default keeps backward compatibility for any
* test/storybook that still mounts `<SourcesSection />` standalone.
* v1.8.7.1 — Sources is its own settings page again (it was merged into
* the combined "Targets & Sources" page under `/settings/thresholds` in
* v1.4.34 IW-D). The section renders its own page header (h1 + subtitle)
* and owns the per-metric source-priority + device-type ladders.
*/
export interface SourcesSectionProps {
mode?: "standalone" | "embedded";
}
export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
export function SourcesSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -292,32 +282,22 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
// v1.4.34 IW-D — `embedded` mode skips the section header because the
// parent surface (`<ThresholdsSection>`) already names the page.
const isStandalone = mode === "standalone";
const Wrapper = isStandalone ? "section" : "div";
const wrapperProps = isStandalone
? {
"aria-labelledby": "settings-section-sources-title",
className: "space-y-6",
}
: { className: "space-y-6" };
return (
<Wrapper {...wrapperProps}>
{isStandalone && (
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
)}
<section
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
@@ -599,7 +579,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
</div>
)}
</div>
</Wrapper>
</section>
);
}
+13 -33
View File
@@ -1,37 +1,24 @@
"use client";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`
* (the "Targets" / "Zielwerte" page).
*
* v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets &
* Sources" and absorbs the former `/settings/sources` content. The two
* editors stay distinct mutation flows (`/api/user/thresholds` for
* ranges + `/api/auth/me/source-priority` for source ladders) so a save
* on one half never disturbs the other; presenting them on a single
* page collapses two adjacent sidebar slots into one and matches the
* Settings nav audit (§7.1 item 3 in
* `.planning/round-v1433-audit-menu.md` — both screens were per-metric
* configuration shelves and reading them together matches how a user
* actually thinks about a metric: "for this metric, which sources do
* I trust AND what range counts as healthy?").
*
* `/settings/sources` stays alive as a `permanentRedirect` (see
* `src/app/settings/sources/page.tsx`) so external bookmarks (iOS
* Settings deep-links, docs) keep resolving.
* v1.8.7.1 — Targets and Sources are two separate settings pages again.
* This page owns only the per-metric target-range editor
* (`/api/user/thresholds`); the source-priority ladders moved back onto
* their own `/settings/sources` page (`<SourcesSection>`). The two were
* merged into one "Targets & Sources" page in v1.4.34 IW-D, but the
* combined surface ran long and the two editors drive distinct mutation
* flows, so each concern is self-contained on its own page.
*
* Visual structure on this page:
* 1. Section header (combined title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs (top).
* The editor card kept its own affordance row (icon, reset-all
* button) so the action surface stays compact.
* 3. `<SourcesSection>` body — per-metric source priority + the
* two-axis device-type expander (bottom). The inner section's
* header was suppressed in `mode="embedded"` because the page
* header above already names the surface.
* 1. Section header (title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs. The editor
* card keeps its own affordance row (icon, reset-all button).
*
* v1.4.16 phase B6: file renamed from the historic
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
@@ -63,15 +50,8 @@ export function ThresholdsSection() {
</p>
</header>
{/* Per-metric threshold ranges — top card. */}
{/* Per-metric threshold ranges. */}
<ThresholdsEditorSection id="thresholds" />
{/* Per-metric source priority + two-axis device-type ladder —
bottom card. `mode="embedded"` suppresses the inner h1 +
subtitle (the page header above owns the surface name) and
drops the inner `<section>` aria-label so the assistive-tech
reading order stays one combined surface, not two. */}
<SourcesSection mode="embedded" />
</section>
);
}