mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(settings): merge Sources into Thresholds as "Targets & Sources"
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.
`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.
`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.
The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.
Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
This commit is contained in:
1 parent
1413bc39c7
commit
fd81d3b806
9 files changed
+167
-51
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -79,6 +79,14 @@ 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";
|
||||
@@ -197,28 +205,26 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> renders heading and embeds the editor (B6 rename)", () => {
|
||||
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
|
||||
// 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>`). 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
|
||||
// `<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.
|
||||
const html = render(<ThresholdsSection />);
|
||||
// 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("<ThresholdsSection> resolves the German title (B6 rename)", () => {
|
||||
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
|
||||
const html = render(<ThresholdsSection />, "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"');
|
||||
});
|
||||
});
|
||||
@@ -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("<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 & Sources");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
@@ -177,7 +184,9 @@ 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");
|
||||
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("<SettingsShell>", () => {
|
||||
// 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", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Download,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
@@ -36,7 +35,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
thresholds: SlidersHorizontal,
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
export: Download,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -98,7 +98,26 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
|
||||
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
|
||||
* (`<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.
|
||||
*
|
||||
* `/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.
|
||||
*/
|
||||
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 (`<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 (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -570,7 +599,7 @@ export function SourcesSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* `<ThresholdsSection>` — 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. `<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.
|
||||
*
|
||||
* v1.4.16 phase B6: file renamed from the historic
|
||||
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`) so the
|
||||
* filename + default export match the slug — every other section in
|
||||
* `src/components/settings/` follows the `<slug>-section.tsx`
|
||||
* `<SlugSection>` convention. The inner editor card was simultaneously
|
||||
* moved out of `thresholds-section.tsx` (where it used to live as
|
||||
* `<ThresholdsSection>`) into `thresholds-editor-section.tsx`
|
||||
* (`<ThresholdsEditorSection>`) so the names stop clashing.
|
||||
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
|
||||
* so the filename + default export match the slug — every other
|
||||
* section in `src/components/settings/` follows the
|
||||
* `<slug>-section.tsx` `<SlugSection>` convention. The inner editor
|
||||
* card was simultaneously moved out of `thresholds-section.tsx`
|
||||
* (where it used to live as `<ThresholdsSection>`) into
|
||||
* `thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`) so
|
||||
* the names stop clashing.
|
||||
*/
|
||||
export function ThresholdsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -35,7 +63,15 @@ export function ThresholdsSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Per-metric threshold ranges — top card. */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user