mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Finish the wiring polish: verbosity gate, upload flash, /about links, dead settings slugs
- The Coach verbosity picker is disabled while the concise tone forces brief, with a hint saying so, instead of a live control that silently does nothing. - A fresh document upload flashes its new row like the duplicate path always did; the settle decision is a pure exported helper with a test. - /about gains its two inbound links: the /privacy footer and a Settings→About credits row (the GeoLite2 EULA wants the attribution reachable from the running app). - The eight settings slugs shadowed by permanent redirects (the seven layout children + sharing) leave the slug registry: no more prerendering unreachable pages, no dead component wirings, and the SectionPlaceholder fallback that could never render is gone. The redirects, section components, and layout-hub subpages are unchanged.
This commit is contained in:
1 parent
3b1314d1b0
commit
542ca91be5
25 files changed
+181
-223
No files matched your search
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Kurz",
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Ausführlich",
|
||||
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
|
||||
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").",
|
||||
"settingsSourcesPointerLink": "Coach öffnen",
|
||||
"settingsExcludeLabel": "Metriken ausschließen",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"credits": "Credits & Datenquellen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
|
||||
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Brief",
|
||||
"settingsVerbosityDefault": "Default",
|
||||
"settingsVerbosityDetailed": "Detailed",
|
||||
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
|
||||
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
|
||||
"settingsSourcesPointerLink": "Open the coach",
|
||||
"settingsExcludeLabel": "Exclude metrics",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
|
||||
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Breve",
|
||||
"settingsVerbosityDefault": "Estándar",
|
||||
"settingsVerbosityDetailed": "Detallado",
|
||||
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
|
||||
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
|
||||
"settingsSourcesPointerLink": "Abrir el coach",
|
||||
"settingsExcludeLabel": "Excluir métricas",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Código fuente",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
},
|
||||
|
||||
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Bref",
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Détaillé",
|
||||
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
|
||||
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
|
||||
"settingsSourcesPointerLink": "Ouvrir le coach",
|
||||
"settingsExcludeLabel": "Exclure des métriques",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Code source",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
},
|
||||
|
||||
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Breve",
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Dettagliata",
|
||||
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
|
||||
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
|
||||
"settingsSourcesPointerLink": "Apri il coach",
|
||||
"settingsExcludeLabel": "Escludi metriche",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Codice sorgente",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
},
|
||||
|
||||
@@ -2783,6 +2783,7 @@
|
||||
"settingsVerbosityBrief": "Krótka",
|
||||
"settingsVerbosityDefault": "Standardowa",
|
||||
"settingsVerbosityDetailed": "Szczegółowa",
|
||||
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
|
||||
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
|
||||
"settingsSourcesPointerLink": "Otwórz coacha",
|
||||
"settingsExcludeLabel": "Wyklucz metryki",
|
||||
@@ -5952,6 +5953,7 @@
|
||||
"repository": "Kod źródłowy",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
},
|
||||
|
||||
@@ -2057,6 +2057,18 @@ export default function PrivacyPage() {
|
||||
</a>
|
||||
. Policy version {POLICY_VERSION}. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
{/* The GeoLite2 EULA wants the attribution page reachable from the
|
||||
running app — this and the Settings→About row are the two
|
||||
inbound links to /about. */}
|
||||
<p className="mt-2">
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-foreground hover:underline"
|
||||
data-slot="privacy-about-link"
|
||||
>
|
||||
About HealthLog — credits & data attributions
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,27 +10,16 @@ import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { CoachSection } from "@/components/settings/coach-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { EnvironmentSection } from "@/components/settings/environment-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
|
||||
import { IllnessSection } from "@/components/settings/illness-section";
|
||||
import { InsightsSection } from "@/components/settings/insights-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { LabsSection } from "@/components/settings/labs-section";
|
||||
import { LayoutSection } from "@/components/settings/layout-section";
|
||||
import { MedicationsSection } from "@/components/settings/medications-section";
|
||||
import { McpSection } from "@/components/settings/mcp-section";
|
||||
import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { PrivacySection } from "@/components/settings/privacy-section";
|
||||
import { ScoreSection } from "@/components/settings/score-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { VorsorgeSection } from "@/components/settings/vorsorge-section";
|
||||
// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
|
||||
// Integrations sub-tabs). v1.25.3 — `channels` folded into Notifications.
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
import { SourcesSection } from "@/components/settings/sources-section";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import {
|
||||
@@ -72,21 +61,13 @@ const SECTION_COMPONENTS: Record<
|
||||
sources: SourcesSection,
|
||||
notifications: NotificationsSection,
|
||||
layout: LayoutSection,
|
||||
dashboard: DashboardSection,
|
||||
insights: InsightsSection,
|
||||
medications: MedicationsSection,
|
||||
mood: MoodSection,
|
||||
labs: LabsSection,
|
||||
illness: IllnessSection,
|
||||
environment: EnvironmentSection,
|
||||
anamnesis: AnamnesisSection,
|
||||
score: ScoreSection,
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
mcp: McpSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
advanced: AdvancedSection,
|
||||
privacy: PrivacySection,
|
||||
@@ -107,32 +88,25 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a
|
||||
// slug without a wired component is a compile error — the old runtime
|
||||
// `<SectionPlaceholder>` fallback could never render and was removed.
|
||||
const SectionComponent = SECTION_COMPONENTS[section];
|
||||
// Defensive fallback: in theory unreachable since `isSettingsSectionSlug`
|
||||
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
|
||||
// without a wired component would otherwise crash silently — placeholder
|
||||
// surfaces the gap visually instead.
|
||||
// v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link)
|
||||
// now live in `<SettingsShell>`, which places them in their own grid row
|
||||
// live in `<SettingsShell>`, which places them in their own grid row
|
||||
// spanning only the content column so the left nav's first item lines up
|
||||
// with the top of the first card. The page body is pure card content,
|
||||
// wrapped in the labelled `<section>` so the historic
|
||||
// `settings-section-<slug>-title` `aria-labelledby` linkage still resolves.
|
||||
const body = SectionComponent ? (
|
||||
<section
|
||||
aria-labelledby={`settings-section-${section}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<SectionComponent />
|
||||
</section>
|
||||
) : (
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsShell active={section}>
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
{body}
|
||||
<section
|
||||
aria-labelledby={`settings-section-${section}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<SectionComponent />
|
||||
</section>
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,8 @@ import { permanentRedirect } from "next/navigation";
|
||||
*
|
||||
* Pre-v1.4 the entire settings UI lived in this single 3000+ LOC file. As part
|
||||
* of the v1.4 settings split (PR series A2), each concern moved to its own
|
||||
* route under `/settings/[section]/page.tsx`. The historic monolith is
|
||||
* preserved verbatim in `page.legacy.tsx` for section-by-section extraction in
|
||||
* the follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
|
||||
* A2-notifications, A2-rest).
|
||||
* route under `/settings/[section]/page.tsx`; the extraction finished long
|
||||
* ago and the historic monolith is gone.
|
||||
*
|
||||
* 308 (`permanentRedirect`) keeps the request method intact and is the right
|
||||
* answer for a permanent address change — bookmarks, deep-links, and any
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The upload manager's settle decision (§3.2 + the fresh-upload flash).
|
||||
*
|
||||
* Watched red: with `settleDecision` returning `flashId: null` on the
|
||||
* non-duplicate branch (the pre-v1.37.19 behaviour — only the duplicate
|
||||
* path flashed the row), the fresh-upload case below fails. A fresh upload
|
||||
* used to give no feedback beyond its queue row disappearing.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { settleDecision } from "../use-document-upload";
|
||||
import type { UploadResult } from "../vault-utils";
|
||||
|
||||
function success(duplicate: boolean): UploadResult {
|
||||
return {
|
||||
ok: true,
|
||||
duplicate,
|
||||
document: { id: "doc-1" },
|
||||
} as unknown as UploadResult;
|
||||
}
|
||||
|
||||
describe("settleDecision", () => {
|
||||
it("flashes the NEW row on a fresh upload (no toast)", () => {
|
||||
expect(settleDecision(success(false))).toEqual({
|
||||
removeFromQueue: true,
|
||||
toastDuplicate: false,
|
||||
flashId: "doc-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("flashes the EXISTING row and toasts on a duplicate", () => {
|
||||
expect(settleDecision(success(true))).toEqual({
|
||||
removeFromQueue: true,
|
||||
toastDuplicate: true,
|
||||
flashId: "doc-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a failed upload in the queue with no flash", () => {
|
||||
expect(
|
||||
settleDecision({ ok: false, reason: "generic" } as UploadResult),
|
||||
).toEqual({ removeFromQueue: false, toastDuplicate: false, flashId: null });
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,29 @@ function uploadViaXhr(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure settle decision — what a finished upload does to the UI.
|
||||
*
|
||||
* Every success flashes the row (fresh upload → the new row, duplicate →
|
||||
* the existing row); only the duplicate additionally toasts. Exported for
|
||||
* the unit test: until v1.37.19 only the duplicate branch flashed, so a
|
||||
* fresh upload gave no feedback beyond its queue row disappearing.
|
||||
*/
|
||||
export function settleDecision(result: UploadResult): {
|
||||
removeFromQueue: boolean;
|
||||
toastDuplicate: boolean;
|
||||
flashId: string | null;
|
||||
} {
|
||||
if (!result.ok) {
|
||||
return { removeFromQueue: false, toastDuplicate: false, flashId: null };
|
||||
}
|
||||
return {
|
||||
removeFromQueue: true,
|
||||
toastDuplicate: result.duplicate,
|
||||
flashId: result.document.id,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocumentUploadManager {
|
||||
/** Live queue — uploading entries first-in-first, then error entries. */
|
||||
items: UploadQueueItem[];
|
||||
@@ -196,16 +219,17 @@ export function useDocumentUpload(
|
||||
|
||||
const settle = useCallback(
|
||||
(localId: string, result: UploadResult) => {
|
||||
if (result.ok) {
|
||||
removeItem(localId);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
if (result.duplicate) {
|
||||
toast.info(t("documents.toast.duplicate"));
|
||||
flashHighlight(result.document.id);
|
||||
}
|
||||
if (!result.ok) {
|
||||
patchItem(localId, { status: "error", failure: result, progress: 0 });
|
||||
return;
|
||||
}
|
||||
patchItem(localId, { status: "error", failure: result, progress: 0 });
|
||||
const decision = settleDecision(result);
|
||||
removeItem(localId);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
if (decision.toastDuplicate) {
|
||||
toast.info(t("documents.toast.duplicate"));
|
||||
}
|
||||
if (decision.flashId) flashHighlight(decision.flashId);
|
||||
},
|
||||
[flashHighlight, patchItem, queryClient, removeItem, t],
|
||||
);
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("IntegrationStatusPill", () => {
|
||||
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain("Paused");
|
||||
expect(html).toContain("reconnect manually");
|
||||
expect(html).toContain("Paused — reconnect");
|
||||
expect(html).toContain('data-state="parked"');
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("IntegrationStatusPill", () => {
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Pausiert");
|
||||
expect(html).toContain("manuell wieder verbinden");
|
||||
expect(html).toContain("Pausiert — neu verbinden");
|
||||
});
|
||||
|
||||
// The three states the server verdict added. `stale` and `stalled` both
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
// The pill flips to `data-state="parked"` and the resume banner
|
||||
// renders with the reconnect CTA. The legacy `error` reconnect text
|
||||
// (red) must NOT appear because the user-facing copy is the lighter
|
||||
// "Paused — reconnect manually" string, not "Error — reconnect".
|
||||
// "Paused — reconnect" string, not "Error — reconnect".
|
||||
it("Withings parked state surfaces the parked pill + resume CTA", () => {
|
||||
setIntegrationStatus({
|
||||
threshold: 3,
|
||||
@@ -246,7 +246,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-state="parked"');
|
||||
expect(html).toContain("Paused");
|
||||
expect(html).toContain("reconnect manually");
|
||||
expect(html).toContain("Paused — reconnect");
|
||||
expect(html).toContain('data-testid="withings-parked-banner"');
|
||||
expect(html).toContain('data-testid="withings-resume-button"');
|
||||
// The lastError is surfaced under the pill so the operator sees
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("../record-anamnesis-section", () => ({
|
||||
|
||||
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
|
||||
|
||||
function render(section: "modules" | "integrations" | "anamnesis" | "labs") {
|
||||
function render(section: "modules" | "integrations" | "anamnesis" | "score") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
@@ -140,7 +140,7 @@ describe("RecordSettingsSectionGate", () => {
|
||||
});
|
||||
|
||||
it("does not lend its page to another destination of the same kind", () => {
|
||||
// `labs` is `adult-shared-unavailable` today, so this passes for the
|
||||
// `score` is `adult-shared-unavailable` today, so this passes for the
|
||||
// right reason now; the claim it pins is that the branch asks WHICH
|
||||
// page, not which category. A second `manage-writable` slug added
|
||||
// without an arm falls through to the refusal panel.
|
||||
@@ -153,7 +153,7 @@ describe("RecordSettingsSectionGate", () => {
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const html = render("labs");
|
||||
const html = render("score");
|
||||
|
||||
expect(html).not.toContain("record-anamnesis");
|
||||
expect(html).toContain("shared-record-settings-unavailable-title");
|
||||
|
||||
@@ -15,13 +15,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "../section-slugs";
|
||||
|
||||
export function SettingsSectionFrame({
|
||||
slug,
|
||||
children,
|
||||
}: {
|
||||
slug: SettingsSectionSlug;
|
||||
/**
|
||||
* A section slug OR a layout-hub child id (`dashboard`, `mood`, …) — the
|
||||
* children left `SETTINGS_SECTION_SLUGS` in v1.37.19 but their heading
|
||||
* keys and section bodies live on under `/settings/layout/<module>`.
|
||||
*/
|
||||
slug: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -115,7 +115,8 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
// section in. The heading-level smoke checks render the body THROUGH the frame
|
||||
// (mirroring production) so they assert what the user actually sees.
|
||||
function renderFramed(
|
||||
slug: SettingsSectionSlug,
|
||||
// A slug or a layout-hub child id — see the harness note.
|
||||
slug: SettingsSectionSlug | "dashboard",
|
||||
node: React.ReactElement,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
|
||||
@@ -113,13 +113,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"integrations",
|
||||
"sources",
|
||||
"notifications",
|
||||
// v1.37.19 — the per-module personalization slugs (dashboard /
|
||||
// insights / medications / mood / labs / illness / vorsorge) and
|
||||
// `sharing` left the list: their URLs are owned by the permanent
|
||||
// redirects in next.config.ts, so the dynamic route could never
|
||||
// serve them.
|
||||
"layout",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-ENV) — Environment (home location, travel overrides, backfill),
|
||||
// module-gated on the opt-in `environment` module.
|
||||
"environment",
|
||||
@@ -127,14 +126,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"anamnesis",
|
||||
// v1.35.0 — the Health Score composition surface.
|
||||
"score",
|
||||
"vorsorge",
|
||||
"thresholds",
|
||||
"ai",
|
||||
"coach",
|
||||
"api",
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
@@ -149,6 +146,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
}
|
||||
expect(isSettingsSectionSlug("nope")).toBe(false);
|
||||
// The redirect-shadowed module slugs are no longer sections.
|
||||
expect(isSettingsSectionSlug("dashboard")).toBe(false);
|
||||
expect(isSettingsSectionSlug("sharing")).toBe(false);
|
||||
expect(isSettingsSectionSlug("")).toBe(false);
|
||||
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
|
||||
});
|
||||
@@ -259,17 +259,9 @@ describe("<SettingsShell>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights the Layout hub when the active prop is a Layout child", () => {
|
||||
// The page passes `active={section}` explicitly; a child slug must
|
||||
// still resolve onto the Layout hub for nav highlighting.
|
||||
const html = renderShell({
|
||||
active: "dashboard",
|
||||
pathname: "/settings/dashboard",
|
||||
});
|
||||
const layoutActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
|
||||
});
|
||||
// v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no
|
||||
// page can pass one as the `active` override anymore; the pathname-based
|
||||
// case above is the remaining (defensive) mapping.
|
||||
|
||||
it("no longer surfaces the per-module nav entries (folded into Darstellung, v1.25.7)", () => {
|
||||
// v1.25.7 — Medikamente / Stimmung / Labor / Krankheit / Vorsorge are no
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
@@ -313,6 +314,21 @@ export function AboutSection() {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
{/* /about carries the GeoLite2 attribution its EULA wants
|
||||
reachable from the running app — this row and the /privacy
|
||||
footer are its two inbound links. Same-app route, so no
|
||||
external-link glyph. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/about" data-slot="settings-about-credits-link">
|
||||
<Info className="h-4 w-4" />
|
||||
{t("settings.about.credits")}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
@@ -170,7 +170,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Verbosity */}
|
||||
{/* Verbosity. The concise tone forces "brief" server-side
|
||||
(system-prompt.ts, buildPrefsPrefix), so the picker is
|
||||
disabled there — showing "brief" — with a hint instead of
|
||||
a live control that silently does nothing. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-verbosity"
|
||||
@@ -179,7 +182,8 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
{t("insights.coach.settingsVerbosityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.verbosity}
|
||||
value={draft.tone === "concise" ? "brief" : draft.verbosity}
|
||||
disabled={draft.tone === "concise"}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
@@ -205,6 +209,14 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{draft.tone === "concise" && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="coach-prefs-verbosity-concise-hint"
|
||||
>
|
||||
{t("insights.coach.settingsVerbosityConciseHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("<CsvImportResultView> — result states", () => {
|
||||
);
|
||||
expect(html).toContain('data-outcome="success"');
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain("Imported 4 new and updated 1.");
|
||||
expect(html).toContain("Imported 4 new and updated 1");
|
||||
expect(html).not.toContain("skipped");
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
|
||||
account: "profile",
|
||||
modules: "modules",
|
||||
notifications: "notifications",
|
||||
insights: "insights",
|
||||
thresholds: "thresholds",
|
||||
coach: "coach",
|
||||
} as const;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SectionPlaceholder>` — the temporary body of every `/settings/[section]`
|
||||
* route until the matching extraction PR lands.
|
||||
*
|
||||
* The shell + routing surface ship in PR A2-shell so reviewers can validate
|
||||
* the URL structure in isolation. The follow-up PRs (A2-account, A2-about,
|
||||
* A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder
|
||||
* with the real content for each slug, copying from
|
||||
* `src/app/settings/page.legacy.tsx`.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Blocks,
|
||||
Bot,
|
||||
CalendarCheck,
|
||||
CloudSun,
|
||||
Download,
|
||||
ClipboardList,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Info,
|
||||
KeyRound,
|
||||
Plug,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Lock,
|
||||
Pill,
|
||||
Settings2,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Thermometer,
|
||||
TrendingUp,
|
||||
User,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "./section-slugs";
|
||||
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
security: ShieldCheck,
|
||||
access: UsersRound,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
sources: Layers,
|
||||
notifications: Bell,
|
||||
layout: LayoutDashboard,
|
||||
dashboard: LayoutDashboard,
|
||||
insights: TrendingUp,
|
||||
medications: Pill,
|
||||
mood: Smile,
|
||||
labs: FlaskConical,
|
||||
illness: Thermometer,
|
||||
environment: CloudSun,
|
||||
anamnesis: ClipboardList,
|
||||
score: Gauge,
|
||||
vorsorge: CalendarCheck,
|
||||
thresholds: SlidersHorizontal,
|
||||
ai: Sparkles,
|
||||
coach: Bot,
|
||||
api: KeyRound,
|
||||
mcp: Plug,
|
||||
gesundheitsakte: FileHeart,
|
||||
sharing: Share2,
|
||||
export: Download,
|
||||
advanced: Settings2,
|
||||
privacy: Lock,
|
||||
about: Info,
|
||||
};
|
||||
|
||||
export interface SectionPlaceholderProps {
|
||||
slug: SettingsSectionSlug;
|
||||
}
|
||||
|
||||
export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = SLUG_ICON[slug];
|
||||
const sectionTitle = t(`settings.sections.${slug}.title`);
|
||||
const sectionDescription = t(`settings.sections.${slug}.description`);
|
||||
|
||||
// v1.4.33 A4 — `<SectionPlaceholder>` is a defensive fallback for
|
||||
// slugs added to SETTINGS_SECTION_SLUGS without a matching component.
|
||||
// The type system blocks that path today, so the EmptyState body is
|
||||
// effectively unreachable. We retire the dedicated locale key
|
||||
// (`settings.sections.placeholder.coming_soon`) and inherit the
|
||||
// section's own description so the guard still renders meaningful
|
||||
// copy if it ever does fire — no more dead-string maintenance across
|
||||
// six locale files.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`settings-section-${slug}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1 id={`settings-section-${slug}-title`} className="sr-only">
|
||||
{sectionTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
|
||||
</header>
|
||||
|
||||
<EmptyState
|
||||
icon={<Icon className="size-6" />}
|
||||
title={sectionTitle}
|
||||
description={sectionDescription}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -71,13 +71,15 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// 301-redirects to `/settings/notifications#channels` (next.config.ts).
|
||||
"sources",
|
||||
"notifications",
|
||||
// v1.37.19 — the per-module personalization slugs (`dashboard`,
|
||||
// `insights`, `medications`, `mood`, `labs`, `illness`, `vorsorge`) and
|
||||
// `sharing` left this list. Their URLs are owned by permanent redirects
|
||||
// in `next.config.ts` (`/settings/<module>` → `/settings/layout/<module>`,
|
||||
// `/settings/sharing` → `/settings/gesundheitsakte#sharing`), so the
|
||||
// dynamic route could never serve them — prerendering eight unreachable
|
||||
// pages and keeping eight dead component wirings. The redirects, the
|
||||
// section components, and the layout-hub subpages are unchanged.
|
||||
"layout",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel
|
||||
// overrides, and the weather/daylight backfill. Module-gated on the opt-in
|
||||
// `environment` module (the nav entry only shows when it is on).
|
||||
@@ -92,7 +94,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// shown) and folding the two together is what kept the score's
|
||||
// composition invisible.
|
||||
"score",
|
||||
"vorsorge",
|
||||
"thresholds",
|
||||
"ai",
|
||||
// v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable
|
||||
@@ -106,13 +107,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// access surfaces). Not nav-gated — the card carries its own enable toggle.
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
// v1.18.7 — `sharing` (clinician share links) sits directly after
|
||||
// `gesundheitsakte`, before `export`: minting a time-boxed read-only link
|
||||
// to the health record belongs next to the health-record export. Always
|
||||
// available (no module gate), like account / export. The backing model,
|
||||
// the `/api/share-links` routes, and the public `/c/[token]` view are
|
||||
// unchanged — this restores only the owner Settings surface.
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
// v1.23 — "Data & Privacy": a single surface that assembles the already-
|
||||
|
||||
@@ -25,7 +25,10 @@ describe("isDisclaimerAcknowledgmentCurrent", () => {
|
||||
|
||||
it("re-prompts when the stored acknowledgment is for an older version", () => {
|
||||
expect(
|
||||
isDisclaimerAcknowledgmentCurrent("2026-06-20T08:00:00.000Z", "2025-01-01"),
|
||||
isDisclaimerAcknowledgmentCurrent(
|
||||
"2026-06-20T08:00:00.000Z",
|
||||
"2025-01-01",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -78,23 +78,15 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
sources: UNAVAILABLE,
|
||||
notifications: MANAGED_GUARDIAN,
|
||||
layout: ADULT_SHARED_UNAVAILABLE,
|
||||
dashboard: ADULT_SHARED_UNAVAILABLE,
|
||||
insights: MANAGED_GUARDIAN,
|
||||
medications: ADULT_SHARED_UNAVAILABLE,
|
||||
mood: ADULT_SHARED_UNAVAILABLE,
|
||||
labs: ADULT_SHARED_UNAVAILABLE,
|
||||
illness: ADULT_SHARED_UNAVAILABLE,
|
||||
environment: UNAVAILABLE,
|
||||
anamnesis: MANAGE_WRITABLE,
|
||||
score: ADULT_SHARED_UNAVAILABLE,
|
||||
vorsorge: ADULT_SHARED_UNAVAILABLE,
|
||||
thresholds: MANAGED_GUARDIAN,
|
||||
ai: UNAVAILABLE,
|
||||
coach: MANAGED_GUARDIAN,
|
||||
api: PERSONAL,
|
||||
mcp: PERSONAL,
|
||||
gesundheitsakte: PERSONAL,
|
||||
sharing: UNAVAILABLE,
|
||||
export: PERSONAL,
|
||||
advanced: PERSONAL,
|
||||
privacy: PERSONAL,
|
||||
|
||||
Reference in new issue
Block a user