diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index fda17e53f..81b86cce4 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -11,7 +11,10 @@ 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";
-import { SourcesSection } from "@/components/settings/sources-section";
+// 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.
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
SETTINGS_SECTION_SLUGS,
@@ -46,7 +49,6 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
- sources: SourcesSection,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
diff --git a/src/app/settings/sources/page.tsx b/src/app/settings/sources/page.tsx
new file mode 100644
index 000000000..198bb3d30
--- /dev/null
+++ b/src/app/settings/sources/page.tsx
@@ -0,0 +1,23 @@
+import { permanentRedirect } from "next/navigation";
+
+/**
+ * v1.4.34 IW-D — `/settings/sources` permanent redirect.
+ *
+ * The Sources settings section was merged into `/settings/thresholds`
+ * (now titled "Targets & Sources" / "Zielwerte & Quellen"). The two
+ * editors share the same per-metric configuration shelf and reading
+ * them side-by-side on one page matches how a user actually thinks
+ * about metric configuration ("for this metric, which sources do I
+ * trust AND what range counts as healthy?"). Per
+ * `.planning/research/v1434-r-2-carryover-scope.md` §7 and
+ * `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
+ *
+ * 308 (`permanentRedirect`) keeps the request method intact so any
+ * external system that POSTs (none today, but cheap insurance) follows
+ * through with its original method. Existing bookmarks, iOS Settings
+ * deep-links, and any docs that referenced `/settings/sources`
+ * continue to resolve.
+ */
+export default function SettingsSourcesRedirect(): never {
+ permanentRedirect("/settings/thresholds");
+}
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 5e6f4520f..170f0c6f1 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -79,6 +79,14 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () =>
,
}));
+// v1.4.34 IW-D — `` now also embeds
+// `` 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: () => ,
+}));
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
@@ -197,28 +205,26 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Quellen & Dokumentation");
});
- it(" renders heading and embeds the editor (B6 rename)", () => {
+ it(" renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
// v1.4.16 phase B6 contract: the route wrapper is named
// `` (was ``), lives
// at `thresholds-section.tsx`, and embeds the inner editor under
- // `` (was ``). The
- // mock above stubs the editor as `data-testid="thresholds-editor"`,
- // so this test asserts:
- // - the wrapper renders without throwing
- // - the localised page title surfaces
- // - the editor mount-point is present
- // - no raw i18n key leaks
+ // `` (was ``).
+ // v1.4.34 IW-D — the wrapper now also embeds
+ // `` so the page is the merged
+ // "Targets & Sources" surface. Both editor mounts must be present.
const html = render();
- // F-07 (v1.4.19): page title aligned with the URL slug — was
- // "Personal targets", now "Personal thresholds".
- expect(html).toContain("Personal thresholds");
+ // v1.4.34 IW-D: section renamed to "Targets & Sources".
+ expect(html).toContain("Targets & Sources");
expect(html).toContain('data-testid="thresholds-editor"');
+ expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain("settings.sections.thresholds.");
});
- it(" resolves the German title (B6 rename)", () => {
+ it(" resolves the German title (B6 rename + IW-D merge)", () => {
const html = render(, "de");
- expect(html).toContain("Persönliche Zielwerte");
+ expect(html).toContain("Zielwerte & Quellen");
expect(html).toContain('data-testid="thresholds-editor"');
+ expect(html).toContain('data-testid="sources-section"');
});
});
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 66385f056..c5f61ea90 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
- it("declares the eleven sections agreed for v1.4.25", () => {
+ it("declares the ten sections agreed for v1.4.34", () => {
// 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`,
@@ -48,13 +48,15 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// 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).
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
- "sources",
"ai",
"api",
"export",
@@ -162,6 +164,11 @@ describe("", () => {
// 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 & Sources");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -177,7 +184,9 @@ describe("", () => {
// 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");
- expect(html).toContain("Persönliche Zielwerte");
+ // v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
+ // "Zielwerte & Quellen" (combined with the former Sources section).
+ expect(html).toContain("Zielwerte & 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");
@@ -187,6 +196,9 @@ describe("", () => {
// 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"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
index 3f05ea25d..0a8681a54 100644
--- a/src/components/settings/section-placeholder.tsx
+++ b/src/components/settings/section-placeholder.tsx
@@ -16,7 +16,6 @@ import {
Download,
Info,
KeyRound,
- Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -36,7 +35,6 @@ const SLUG_ICON: Record = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
- sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index 9004e4af0..e242d9891 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -6,13 +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).
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
- "sources",
"ai",
"api",
"export",
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 6fff0f990..dca91628b 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -21,7 +21,6 @@ import {
Bell,
Download,
KeyRound,
- Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -73,6 +72,15 @@ interface SettingsSection {
* keeps emitting the page — but it no longer appears in the in-page
* 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.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -96,11 +104,6 @@ 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 },
{
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index ecb6c24f8..ce96c3eab 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -98,7 +98,26 @@ const DEVICE_TYPE_LABEL_KEYS: Record = {
unknown: "settings.sections.sources.deviceLabels.unknown",
};
-export function SourcesSection() {
+/**
+ * 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
+ * (`` on `/settings/thresholds`) already names
+ * the page. The section becomes a `` instead of a
+ * `
` so the assistive-tech reading order stays one
+ * combined surface.
+ *
+ * `/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 `` standalone.
+ */
+export interface SourcesSectionProps {
+ mode?: "standalone" | "embedded";
+}
+
+export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -273,22 +292,32 @@ export function SourcesSection() {
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
+ // v1.4.34 IW-D — `embedded` mode skips the section header because the
+ // parent surface (``) 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 (
-
-
+
+ {isStandalone && (
+
+ )}
@@ -570,7 +599,7 @@ export function SourcesSection() {
)}
-
+
);
}
diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx
index 68cd4d93b..096045b09 100644
--- a/src/components/settings/thresholds-section.tsx
+++ b/src/components/settings/thresholds-section.tsx
@@ -1,19 +1,47 @@
"use client";
+import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `` — route-level wrapper for `/settings/thresholds`.
*
+ * 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.
+ *
+ * Visual structure on this page:
+ * 1. Section header (combined title + description).
+ * 2. `` — per-metric range inputs (top).
+ * The editor card kept its own affordance row (icon, reset-all
+ * button) so the action surface stays compact.
+ * 3. `` 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.
+ *
* v1.4.16 phase B6: file renamed from the historic
- * `thresholds-settings-section.tsx` (``) so the
- * filename + default export match the slug — every other section in
- * `src/components/settings/` follows the `-section.tsx`
- * `` convention. The inner editor card was simultaneously
- * moved out of `thresholds-section.tsx` (where it used to live as
- * ``) into `thresholds-editor-section.tsx`
- * (``) so the names stop clashing.
+ * `thresholds-settings-section.tsx` (``)
+ * so the filename + default export match the slug — every other
+ * section in `src/components/settings/` follows the
+ * `-section.tsx` `` convention. The inner editor
+ * card was simultaneously moved out of `thresholds-section.tsx`
+ * (where it used to live as ``) into
+ * `thresholds-editor-section.tsx` (``) so
+ * the names stop clashing.
*/
export function ThresholdsSection() {
const { t } = useTranslations();
@@ -35,7 +63,15 @@ export function ThresholdsSection() {
+ {/* Per-metric threshold ranges — top card. */}
+
+ {/* 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 `` aria-label so the assistive-tech
+ reading order stays one combined surface, not two. */}
+
);
}