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:
Marc-André Bombeck committed 2026-08-14 05:31:33 +02:00
1 parent 3b1314d1b0
commit 542ca91be5
25 files changed
+181 -223

No files matched your search

@@ -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
+16
View File
@@ -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>
);
}
+8 -14
View File
@@ -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-