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 ( -
-
-

- {t("settings.sections.sources.title")} -

-

- {t("settings.sections.sources.description")} -

-
+ + {isStandalone && ( +
+

+ {t("settings.sections.sources.title")} +

+

+ {t("settings.sections.sources.description")} +

+
+ )}
@@ -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. */} +
); }