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

-1
View File
@@ -58,7 +58,6 @@ const SETTINGS_ROUTES = [
"/settings/api",
"/settings/mcp",
"/settings/advanced",
"/settings/preview",
"/settings/about",
] as const;
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Geteilter Zugriff",
"subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst."
},
"preview": {
"title": "Vorschau-Funktionen",
"subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest."
}
},
"profile": "Profil",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey entfernt.",
"integrationDisconnected": "{provider} getrennt.",
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.",
"preview": {
"topics": {
"title": "Themen (Beta)",
"description": "Fügt einen eigenen Eintrag Themen hinzu, sobald die Übersichtsseiten über den einzelnen Insights-Seiten fertig sind.",
"detail": "Deine Insights-Seiten, die Navigation und alle Links bleiben genau wie sie sind. Die Themenseiten kommen mit einem späteren Update; dieser Schalter legt fest, ob du sie siehst.",
"savedOn": "Themen-Vorschau eingeschaltet.",
"savedOff": "Themen-Vorschau ausgeschaltet."
},
"loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden.",
"feedbackLink": "Feedback in den GitHub-Diskussionen geben"
},
"timezoneSaved": "Zeitzone gespeichert.",
"moduleState": {
"on": "An",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Shared access",
"subtitle": "Who can open your health record, and whose you can open."
},
"preview": {
"title": "Preview features",
"subtitle": "Try what is not finished yet. Everything here stays off until you switch it on."
}
},
"profile": "Profile",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey removed.",
"integrationDisconnected": "{provider} disconnected.",
"integrationDisconnectFailed": "Could not disconnect {provider}.",
"preview": {
"topics": {
"title": "Topics (beta)",
"description": "Will add a separate Topics entry with overview pages above the single-metric Insights pages once they are ready.",
"detail": "Your Insights pages, navigation and links stay exactly as they are. The topic pages arrive in a later update; this switch decides whether you see them.",
"savedOn": "Topics preview switched on.",
"savedOff": "Topics preview switched off."
},
"loadError": "Could not load your preview settings.",
"feedbackLink": "Share feedback in the GitHub discussions"
},
"timezoneSaved": "Timezone saved.",
"moduleState": {
"on": "On",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Acceso compartido",
"subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú."
},
"preview": {
"title": "Funciones en vista previa",
"subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives."
}
},
"profile": "Perfil",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey eliminada.",
"integrationDisconnected": "{provider} desconectado.",
"integrationDisconnectFailed": "No se pudo desconectar {provider}.",
"preview": {
"topics": {
"title": "Temas (beta)",
"description": "Añadirá una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica cuando estén listas.",
"detail": "Tus páginas de análisis, la navegación y los enlaces se quedan exactamente como están. Las páginas de temas llegan en una actualización posterior; este interruptor decide si las ves.",
"savedOn": "Vista previa de Temas activada.",
"savedOff": "Vista previa de Temas desactivada."
},
"loadError": "No se pudieron cargar tus ajustes de vista previa.",
"feedbackLink": "Comparte tu opinión en las discusiones de GitHub"
},
"timezoneSaved": "Zona horaria guardada.",
"moduleState": {
"on": "Activado",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Accès partagé",
"subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir."
},
"preview": {
"title": "Fonctions en aperçu",
"subtitle": "Essayez ce qui n’est pas encore terminé. Tout reste désactivé ici tant que vous ne l’activez pas."
}
},
"profile": "Profil",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey supprimée.",
"integrationDisconnected": "{provider} déconnecté.",
"integrationDisconnectFailed": "Impossible de déconnecter {provider}.",
"preview": {
"topics": {
"title": "Thèmes (bêta)",
"description": "Ajoutera une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure, dès qu'elles seront prêtes.",
"detail": "Vos pages d’analyse, la navigation et les liens restent exactement tels quels. Les pages de thèmes arrivent dans une mise à jour ultérieure ; cet interrupteur décide si vous les voyez.",
"savedOn": "Aperçu des thèmes activé.",
"savedOff": "Aperçu des thèmes désactivé."
},
"loadError": "Impossible de charger vos réglages d’aperçu.",
"feedbackLink": "Donner votre avis dans les discussions GitHub"
},
"timezoneSaved": "Fuseau horaire enregistré.",
"moduleState": {
"on": "Activé",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Accesso condiviso",
"subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu."
},
"preview": {
"title": "Funzioni in anteprima",
"subtitle": "Prova ciò che non è ancora finito. Qui tutto resta spento finché non lo attivi."
}
},
"profile": "Profilo",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey rimossa.",
"integrationDisconnected": "{provider} disconnesso.",
"integrationDisconnectFailed": "Impossibile disconnettere {provider}.",
"preview": {
"topics": {
"title": "Temi (beta)",
"description": "Aggiungerà una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche, appena saranno pronte.",
"detail": "Le pagine delle analisi, la navigazione e i link restano esattamente come sono. Le pagine dei temi arrivano con un aggiornamento successivo; questo interruttore decide se le vedi.",
"savedOn": "Anteprima dei temi attivata.",
"savedOff": "Anteprima dei temi disattivata."
},
"loadError": "Impossibile caricare le impostazioni di anteprima.",
"feedbackLink": "Lascia un parere nelle discussioni su GitHub"
},
"timezoneSaved": "Fuso orario salvato.",
"moduleState": {
"on": "Attivo",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "공유 접근",
"subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요."
},
"preview": {
"title": "미리 보기 기능",
"subtitle": "아직 완성되지 않은 기능을 써 보세요. 직접 켜기 전까지는 모두 꺼져 있습니다."
}
},
"profile": "프로필",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "패스키를 삭제했어요.",
"integrationDisconnected": "{provider} 연결을 해제했어요.",
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.",
"preview": {
"topics": {
"title": "주제 (베타)",
"description": "준비되면 개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가해요.",
"detail": "인사이트 페이지, 메뉴, 링크는 지금 그대로 유지됩니다. 주제 페이지는 이후 업데이트에서 제공되며, 이 스위치로 표시 여부를 정합니다.",
"savedOn": "주제 미리 보기를 켰습니다.",
"savedOff": "주제 미리 보기를 껐습니다."
},
"loadError": "미리 보기 설정을 불러오지 못했습니다.",
"feedbackLink": "GitHub 토론에서 의견 남기기"
},
"timezoneSaved": "시간대를 저장했습니다.",
"moduleState": {
"on": "켜짐",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Gedeelde toegang",
"subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen."
},
"preview": {
"title": "Previewfuncties",
"subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet."
}
},
"profile": "Profiel",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Passkey verwijderd.",
"integrationDisconnected": "{provider} ontkoppeld.",
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.",
"preview": {
"topics": {
"title": "Onderwerpen (bèta)",
"description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting, zodra die klaar zijn.",
"detail": "Je inzichtpagina’s, navigatie en links blijven precies zoals ze zijn. De onderwerppagina’s komen met een latere update; deze schakelaar bepaalt of je ze ziet.",
"savedOn": "Preview van onderwerpen ingeschakeld.",
"savedOff": "Preview van onderwerpen uitgeschakeld."
},
"loadError": "Je preview-instellingen konden niet worden geladen.",
"feedbackLink": "Geef feedback in de GitHub-discussies"
},
"timezoneSaved": "Tijdzone opgeslagen.",
"moduleState": {
"on": "Aan",
-15
View File
@@ -6141,10 +6141,6 @@
"access": {
"title": "Udostępniony dostęp",
"subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty."
},
"preview": {
"title": "Funkcje w podglądzie",
"subtitle": "Wypróbuj to, co nie jest jeszcze gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz."
}
},
"profile": "Profil",
@@ -7628,17 +7624,6 @@
"passkeyRemovedToast": "Usunięto passkey.",
"integrationDisconnected": "Rozłączono {provider}.",
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.",
"preview": {
"topics": {
"title": "Tematy (beta)",
"description": "Doda osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów, gdy będą gotowe.",
"detail": "Strony analiz, nawigacja i linki pozostają dokładnie takie, jakie są. Strony tematów pojawią się w późniejszej aktualizacji; ten przełącznik decyduje, czy je zobaczysz.",
"savedOn": "Podgląd tematów włączony.",
"savedOff": "Podgląd tematów wyłączony."
},
"loadError": "Nie udało się wczytać ustawień podglądu.",
"feedbackLink": "Podziel się opinią w dyskusjach na GitHubie"
},
"timezoneSaved": "Strefa czasowa zapisana.",
"moduleState": {
"on": "Włączone",
+3 -3
View File
@@ -388,9 +388,9 @@ model User {
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
moduleListLayoutJson Json? @map("module_list_layout_json")
/// Per-user opt-in to preview features, one boolean per flag, all off by
/// default (`src/lib/settings/preview-features.ts`). Read and written by
/// Settings > System through `/api/settings/preview-features`.
/// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will.
/// default (`src/lib/settings/preview-features.ts`). Read and written
/// through `/api/settings/preview-features` and carried by the backup.
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
previewFeaturesJson Json? @map("preview_features_json")
/// Optional activity goals the person set for themselves: workouts, active
/// minutes and steps per week, steps per month. Null or an absent key = no
+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.