diff --git a/messages/de.json b/messages/de.json index 70fa53e4f..c58deb2ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Hauptnavigation", "mobileNavigation": "Mobile Navigation", + "breadcrumb": "Brotkrumen-Navigation", "loadingScreen": "Laden…", "more": "Mehr", "moreSheetTitle": "Mehr", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Diagramm wird geladen…", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", diff --git a/messages/en.json b/messages/en.json index 5dc198ee5..1e88b791f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", + "breadcrumb": "Breadcrumb navigation", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Loading chart…", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", diff --git a/messages/es.json b/messages/es.json index 01bcc7471..5b0eed9bd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", + "breadcrumb": "Ruta de navegación", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Cargando gráfico…", "weight": "Peso", "bloodPressure": "Presión arterial", "pulse": "Pulso", diff --git a/messages/fr.json b/messages/fr.json index 78854aa7d..53b130e73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", + "breadcrumb": "Fil d'Ariane", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Chargement du graphique…", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", diff --git a/messages/it.json b/messages/it.json index 5de80ed34..3b151b19f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", + "breadcrumb": "Navigazione breadcrumb", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Caricamento grafico…", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Polso", diff --git a/messages/pl.json b/messages/pl.json index dd271524c..6767bc963 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -182,6 +182,7 @@ "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", + "breadcrumb": "Nawigacja okruszkami", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -760,6 +761,7 @@ } }, "charts": { + "loadingLabel": "Ładowanie wykresu…", "weight": "Waga", "bloodPressure": "Ciśnienie krwi", "pulse": "Tętno", diff --git a/src/app/measurements/__tests__/add-token-resolution.test.ts b/src/app/measurements/__tests__/add-token-resolution.test.ts new file mode 100644 index 000000000..dd227de92 --- /dev/null +++ b/src/app/measurements/__tests__/add-token-resolution.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { + MEASUREMENT_FORM_TYPE_VALUES, + ADD_TOKEN_ALIASES, + resolveAddToken, +} from "@/components/measurements/measurement-form"; + +/** + * F-1 contract — every `?add=` the Insights surfaces ship must + * either land on a real `` row or be an explicit + * legacy alias mapped to one. Without this guard the empty-state CTA + * silently drops the deep link and the dialog never opens — the + * symptom that surfaced as "the Körpertemperatur CTA does nothing". + */ +describe("measurements ?add= deep link", () => { + const repoRoot = join(__dirname, "..", "..", "..", ".."); + const insightsRoot = join(repoRoot, "src", "app", "insights"); + const insightsComponentsRoot = join(repoRoot, "src", "components", "insights"); + + // Captures both `href="/measurements?add=FOO"` literals and the + // dynamic `?add=${prop}` template the shared HealthKit metric page + // uses. The latter is covered by walking `emptyStateCtaType="…"` in + // every `/insights//page.tsx`. + const literalAddPattern = /\/measurements\?add=([A-Z_]+)/g; + const ctaTypePattern = /emptyStateCtaType=\{?["']([A-Z_]+)["']/g; + + function collectFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; + out.push(...collectFiles(full)); + } else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) { + out.push(full); + } + } + return out; + } + + const files = [ + ...collectFiles(insightsRoot), + ...collectFiles(insightsComponentsRoot), + ]; + + const emittedTokens = new Set(); + for (const file of files) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(literalAddPattern)) { + emittedTokens.add(match[1]); + } + for (const match of source.matchAll(ctaTypePattern)) { + emittedTokens.add(match[1]); + } + } + + it("walks at least one `?add=` token under src/app/insights", () => { + // Sanity guard so the regex collector doesn't silently zero out. + expect(emittedTokens.size).toBeGreaterThan(0); + }); + + it("every emitted `?add=` token resolves to a real form type", () => { + const unresolved: string[] = []; + for (const token of emittedTokens) { + if (resolveAddToken(token) === null) unresolved.push(token); + } + expect(unresolved).toEqual([]); + }); + + it("legacy aliases each map to a real form type", () => { + for (const [legacy, canonical] of Object.entries(ADD_TOKEN_ALIASES)) { + expect(MEASUREMENT_FORM_TYPE_VALUES).toContain(canonical); + expect(resolveAddToken(legacy)).toBe(canonical); + } + }); + + it("resolves unknown tokens to null without throwing", () => { + expect(resolveAddToken("NOT_A_TYPE")).toBeNull(); + expect(resolveAddToken(null)).toBeNull(); + expect(resolveAddToken(undefined)).toBeNull(); + expect(resolveAddToken("")).toBeNull(); + }); +}); diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 87c770f2d..5d801db5b 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -2,7 +2,10 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { MeasurementForm } from "@/components/measurements/measurement-form"; +import { + MeasurementForm, + resolveAddToken, +} from "@/components/measurements/measurement-form"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -10,23 +13,6 @@ import { Plus, Loader2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "@/lib/i18n/context"; -// v1.4.27 MB6 — the insights empty-state CTAs link here with a -// `?add=` query param to auto-open the add-measurement dialog -// with the right default type. Keep the allow-list aligned with -// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value -// is dropped silently so a stale link can never trap the user on a -// broken form. -const ALLOWED_ADD_TYPES = new Set([ - "BLOOD_PRESSURE", - "WEIGHT", - "PULSE", - "BMI", - "GLUCOSE", - "TEMPERATURE", - "BODY_FAT", - "HEART_RATE", -]); - export default function MeasurementsPage() { const { isAuthenticated, isLoading } = useAuth(); const router = useRouter(); @@ -52,8 +38,13 @@ export default function MeasurementsPage() { if (addParam && addParam !== consumedAddParam) { setConsumedAddParam(addParam); - if (ALLOWED_ADD_TYPES.has(addParam)) { - setDefaultType(addParam); + // v1.4.34 IW-G — `resolveAddToken` is the single source of truth + // for legacy aliases (`GLUCOSE`, `TEMPERATURE`, …) plus the + // canonical form values. Unknown tokens still drop silently so a + // stale link cannot trap the user on a broken form. + const resolved = resolveAddToken(addParam); + if (resolved) { + setDefaultType(resolved); setDialogOpen(true); } // Drop the query string so the back-button leaves the user on @@ -82,7 +73,10 @@ export default function MeasurementsPage() {

{t("measurements.title")}

-

+ {/* v1.4.34 IW-G — subtitle now stays visible on mobile so + the H1 isn't an unframed label. `text-xs sm:text-sm` + preserves the desktop hierarchy. */} +

{t("measurements.subtitle")}

diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index b7b26b1f0..1b90cc4b3 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -154,7 +154,9 @@ export default function MedicationsPage() {

{t("medications.title")}

-

+ {/* v1.4.34 IW-G — subtitle stays visible on mobile so the + H1 isn't an unframed label. */} +

{t("medications.subtitle")}

diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index c3840d3df..b1b5d7a3c 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -42,7 +42,9 @@ export default function MoodPage() {

{t("mood.title")}

-

+ {/* v1.4.34 IW-G — subtitle stays visible on mobile so the + H1 isn't an unframed label. */} +

{t("mood.subtitle")}

diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 0708429ad..a7601abf6 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -156,6 +156,26 @@ export function AdminShell({ active, children }: AdminShellProps) { const { t } = useTranslations(); const activeSlug = deriveActiveSlug(pathname, active); + // v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so + // the active chip stays in view when the user lands on a deeper + // admin route. Without it the 16-chip strip stays scrolled to the + // left and the active chip lives off-screen, reading as a broken nav. + const mobileStripRef = React.useRef(null); + React.useEffect(() => { + if (typeof window === "undefined") return; + const strip = mobileStripRef.current; + if (!strip) return; + const activeChip = strip.querySelector( + '[aria-current="page"]', + ); + if (!activeChip) return; + activeChip.scrollIntoView({ + block: "nearest", + inline: "center", + behavior: "smooth", + }); + }, [activeSlug]); + // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was @@ -169,13 +189,19 @@ export function AdminShell({ active, children }: AdminShellProps) { horizontal swipe + keyboard arrow scrolling still work, the bar just doesn't draw. Without this the 13-section strip would render an always-on scrollbar that the maintainer kept - mis-attributing to the api-tokens table below it. */} + mis-attributing to the api-tokens table below it. + + v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip + `snap-start` lets a swipe-flick land on the next chip's + leading edge instead of the in-between dead zone, matching + the settings-shell polish. */}