fix(settings): unlist Preview features until the topic pages ship

The switch was stored and shown, but nothing reads the topics flag
yet, so the page promised a change it could not make. The section,
its nav entry, its loader and its copy are gone, so its palette
entry goes with them; /settings/preview is no longer a route. The
stored flag, /api/settings/preview-features and its backup round-trip
stay, and the column's pending-consumer note says why.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:09:40 +02:00
1 parent 704822c010
commit 0918451e02
21 files changed
+23 -327

No files matched your search

+3 -9
View File
@@ -1,11 +1,10 @@
/**
* Copy that promises something not yet shipped says so.
*
* The Topics preview switch exists before its pages do, so its description is
* in the future tense in every locale's source (English) and in German; and
* the workout-route switch is off for new accounts, which is what its detail
* The workout-route switch is off for new accounts, which is what its detail
* says rather than "off by default": an account that already held tracks at
* the upgrade started with it on (migration 0434).
* the upgrade started with it on (migration 0434). (The Topics preview switch
* this file also held is unlisted until its pages ship, and its copy with it.)
*/
import { describe, expect, it } from "vitest";
@@ -13,11 +12,6 @@ import en from "../../messages/en.json";
import de from "../../messages/de.json";
describe("settings copy tense", () => {
it("describes the Topics preview as something it will add", () => {
expect(en.settings.preview.topics.description).toMatch(/^Will add /);
expect(de.settings.preview.topics.description).toMatch(/sobald/);
});
it("says workout routes are off for new accounts", () => {
expect(en.settings.privacy.workoutRoutes.detail).toMatch(
/^Off for new accounts\./,
+1 -4
View File
@@ -146,10 +146,7 @@ describe("historical Settings addresses", () => {
// one silently. Module pages are new in v1.44 and are allowed here.
const listed = new Set(HISTORICAL_SETTINGS_PATHS);
const unlisted = routable.filter(
(path) =>
!listed.has(path) &&
!path.startsWith("/settings/modules/") &&
path !== "/settings/preview",
(path) => !listed.has(path) && !path.startsWith("/settings/modules/"),
);
expect(unlisted).toEqual([]);
});
@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PreviewSection } from "../preview-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
});
if (topics !== undefined) {
client.setQueryData(queryKeys.previewFeatures(), { topics });
}
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<PreviewSection />
</QueryClientProvider>
</I18nProvider>,
);
}
describe("<PreviewSection>", () => {
it("shows the Topics switch off by default, saved on change", () => {
const html = render(false);
expect(html).toContain('data-slot="settings-preview-topics-switch"');
expect(html).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/,
);
expect(html).toContain("Topics (beta)");
// A switch, no Save: one save model per card.
expect(html).not.toContain('type="submit"');
});
it("reads on once the person switched it on", () => {
expect(render(true)).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/,
);
});
it("says the existing pages stay as they are and nothing raw leaks", () => {
const html = render(false, "de");
expect(html).toContain("Themen (Beta)");
expect(html).not.toContain("settings.preview.");
});
});
@@ -170,12 +170,19 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
// v1.44 — per-user preview opt-ins, in the System group before About.
"preview",
"about",
]);
});
it("lists no Preview features page until the topic pages ship", () => {
// The stored flag and its API stay; the page and its nav entry return
// with the first preview the switch actually turns on.
expect(isSettingsSectionSlug("preview")).toBe(false);
expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe(
false,
);
});
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
expect(isSettingsSectionSlug(slug)).toBe(true);
@@ -242,7 +249,6 @@ describe("<SettingsShell>", () => {
gesundheitsakte: "data",
privacy: "data",
advanced: "data",
preview: "system",
about: "system",
});
// Every routable section has a nav entry, and nothing else does.
-116
View File
@@ -1,116 +0,0 @@
"use client";
/**
* Settings > System > Preview features.
*
* One switch per preview feature, each off until the person turns it on, each
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
* preview adds something beside what is there and never replaces a page.
*
* The `topics` flag is stored and shown here; the topic overview pages that
* read it arrive in a later release, and the card says so rather than
* promising a change the switch does not make yet.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { LayoutDashboard } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_PREVIEW_FEATURES,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions";
export function PreviewSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const key = queryKeys.previewFeatures();
const query = useQuery({
queryKey: key,
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
});
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
const toggle = useMutation({
mutationKey: key,
mutationFn: (topics: boolean) =>
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
onMutate: async (topics) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<PreviewFeatures>(key);
queryClient.setQueryData<PreviewFeatures>(key, {
...(previous ?? DEFAULT_PREVIEW_FEATURES),
topics,
});
return { previous };
},
onSuccess: (saved, topics) => {
if (saved) queryClient.setQueryData(key, saved);
toastWrittenOutcome(
"success",
topics
? t("settings.preview.topics.savedOn")
: t("settings.preview.topics.savedOff"),
);
},
onError: (failure, _topics, context) => {
queryClient.setQueryData(key, context?.previous);
showErrorToast(t("settings.savingError"), { cause: failure });
},
});
return (
<SettingsCard data-slot="settings-preview-topics">
<SettingsCardHeader
anchor="preview-topics"
icon={LayoutDashboard}
title={t("settings.preview.topics.title")}
titleId="settings-preview-topics-title"
description={t("settings.preview.topics.description")}
status={
<Switch
data-slot="settings-preview-topics-switch"
checked={features.topics}
disabled={query.isLoading || toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-labelledby="settings-preview-topics-title"
/>
}
/>
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
slot="settings-preview-error"
message={t("settings.preview.loadError")}
onRetry={() => void query.refetch()}
/>
) : null}
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
<p className="text-muted-foreground text-xs">
<InlineAction asChild tone="muted" size="xs">
<Link
href={DISCUSSIONS_URL}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.preview.feedbackLink")}
</Link>
</InlineAction>
</p>
</SettingsCard>
);
}
+4 -3
View File
@@ -114,9 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [
// pieces into one coherent privacy pane. Sits next to `advanced` (the
// destructive-action home) and before `about`.
"privacy",
// v1.44 — "Preview features": per-user opt-in to things finished enough to
// try and not yet the default. Sits in the System group before About.
"preview",
// v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not
// listed until the first preview it switches, the topic pages, ships: a
// switch that changes nothing is a promise the page cannot keep. The stored
// flag, `/api/settings/preview-features` and its backup round-trip stay.
"about",
] as const;
@@ -124,10 +124,6 @@ const LOADERS: Record<
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
preview: () =>
import("@/components/settings/preview-section").then(
(m) => m.PreviewSection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
@@ -162,7 +158,6 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
};
/**
@@ -26,7 +26,6 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -275,12 +274,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "data",
},
// ── System ──
{
slug: "preview",
titleKey: "settings.sections.preview.title",
icon: FlaskConical,
group: "system",
},
{
slug: "about",
titleKey: "settings.sections.about.title",
-3
View File
@@ -44,9 +44,6 @@ export const settingsKeys = {
*/
moduleListLayout: () => ["settings", "module-list-layout"] as const,
/** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */
previewFeatures: () => ["settings", "preview-features"] as const,
/**
* v1.35.0 — which pillars count toward the Health Score, behind
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
@@ -98,7 +98,6 @@ export const SETTINGS_DESTINATION_INVENTORY = {
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,
preview: PERSONAL,
about: PERSONAL,
} as const satisfies Record<
SettingsSectionSlug,
+3 -1
View File
@@ -7,7 +7,9 @@
*
* Flags:
* - `topics`: topic overview pages above the single-metric Insights pages.
* Stored and shown in Settings > System; no surface reads it yet.
* Stored, served by `/api/settings/preview-features` and carried by the
* backup. The Settings > System switch is not listed until the topic pages
* ship, because nothing reads the flag yet.
*
* Unknown keys in a stored blob are dropped on read, so a flag retired later
* disappears cleanly and a malformed blob reads as everything off.