From 8c0593fe79a048fb190946eb1292fe2aef70214e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 21:24:30 +0200 Subject: [PATCH] feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bucket A of the v1.4.3 polish pass. Tile-strip width drifted past the chart-strip width by 16px on >=md because the strip used `-mx-2 px-2` to bleed two pixels past the parent for a snap-scroll feel. The bleed is real on mobile — the horizontal-scroll affordance only reads as a scroll if the first/last tiles touch the viewport edge — but on tablet/desktop where the row fits without scrolling it just made the tile group visibly wider than the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts fit inline, so the two strips share the same content box. Tile heights varied because the blood-pressure tile rendered as two stacked TrendCards (sys + dia) inside one tile slot while every other tile was a single TrendCard. The cleaner fix is one TrendCard that displays the paired value as `117/79` — TrendCard now accepts an optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes ONE BP entry instead of a Fragment of two. Tile count drops from 6 to 5, the strip stops wrapping awkwardly, and the BP card carries the systolic sparkline + traffic-light tooltip while the diastolic value rides along under the same heading. Each tile wrapper got `flex` and the card itself `flex h-full flex-col`, so flex-stretch inside the strip can do its job and the avg7/avg30 row pins to the bottom. Settings nav split: the section formerly titled "Übersicht" was renamed to "Dashboard" (the term every user already knows from the main nav), and the embedded Persönliche Zielwerte panel moved out into its own top-level settings section under `/settings/thresholds`. Two reasons: the panel is heavy (one card per metric) and was visually buried behind the layout customizer, and "Übersicht" leaked internal vocabulary — neither the route slug nor the surrounding nav entries used it. Tests guarding slug order + section count updated in lockstep. Touched: - messages/de.json, messages/en.json - src/app/page.tsx (dashboard tile strip) - src/app/settings/[section]/page.tsx (route wiring) - src/components/charts/trend-card.tsx (secondary-metric prop) - src/components/settings/section-slugs.ts - src/components/settings/settings-shell.tsx (new entry + icon) - src/components/settings/section-placeholder.tsx - src/components/settings/dashboard-section.tsx (drop ThresholdsSection) - src/components/settings/thresholds-settings-section.tsx (new) - src/components/settings/__tests__/{sections,settings-shell}.test.tsx Tests + typecheck green (669/669). Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 8 +- messages/en.json | 6 +- src/app/page.tsx | 100 ++++++++---------- src/app/settings/[section]/page.tsx | 2 + src/components/charts/trend-card.tsx | 39 +++++-- .../settings/__tests__/sections.test.tsx | 9 +- .../__tests__/settings-shell.test.tsx | 15 ++- src/components/settings/dashboard-section.tsx | 2 - .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 1 + src/components/settings/settings-shell.tsx | 6 ++ .../settings/thresholds-settings-section.tsx | 29 +++++ 12 files changed, 144 insertions(+), 75 deletions(-) create mode 100644 src/components/settings/thresholds-settings-section.tsx diff --git a/messages/de.json b/messages/de.json index 834c964cb..b80506688 100644 --- a/messages/de.json +++ b/messages/de.json @@ -890,8 +890,12 @@ "description": "Telegram, ntfy, Web Push." }, "dashboard": { - "title": "Übersicht", - "description": "Kachel-Layout, Schwellwerte." + "title": "Dashboard", + "description": "Kachel-Layout und Reihenfolge." + }, + "thresholds": { + "title": "Persönliche Zielwerte", + "description": "Eigene Zielbereiche für jede Metrik." }, "ai": { "title": "KI-Auswertungen", diff --git a/messages/en.json b/messages/en.json index 0b2704c5d..2171969f1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -891,7 +891,11 @@ }, "dashboard": { "title": "Dashboard", - "description": "Tile layout, thresholds." + "description": "Tile layout and order." + }, + "thresholds": { + "title": "Personal targets", + "description": "Override target ranges for every metric." }, "ai": { "title": "AI Insights", diff --git a/src/app/page.tsx b/src/app/page.tsx index 456cc3936..3a85e8e18 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -460,66 +460,50 @@ export default function DashboardPage() { }); } if (showBpCards) { + // BP renders as ONE tile with paired sys/dia values (e.g. "117/79") + // so the strip width matches the chart strip below — and so the + // tile count stays at 5 instead of 6 with a wrap-around. The + // sparkline/trend arrow follows the systolic value (the common + // primary signal); the colour-banding tooltips cover sys only + // because dia uses a different target range and stuffing both + // into one hint became unreadable on mobile. trendCards.push({ id: "bp", order: widgetOrder("bp"), node: ( - - - - + ), }); } @@ -868,14 +852,14 @@ export default function DashboardPage() { * touch. */}
{trendCards.map((entry) => (
{entry.node}
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 31234c0b9..2e4ab29bd 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; +import { ThresholdsSettingsSection } from "@/components/settings/thresholds-settings-section"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -42,6 +43,7 @@ const SECTION_COMPONENTS: Record< integrations: IntegrationsSection, notifications: NotificationsSection, dashboard: DashboardSection, + thresholds: ThresholdsSettingsSection, api: ApiSection, advanced: AdvancedSection, }; diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 7ddbffce1..4ca248a9a 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -11,6 +11,13 @@ import { } from "@/components/ui/tooltip"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +interface SecondaryMetric { + /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ + latest: number | null; + avg7: number | null; + avg30: number | null; +} + interface TrendCardProps { label: string; latest: number | null; @@ -23,6 +30,10 @@ interface TrendCardProps { avg30Hint?: React.ReactNode; slope30: TrendSlope | null; icon: React.ComponentType<{ className?: string }>; + /** Optional second value rendered next to the primary as `X / Y` (used for + * paired metrics like blood-pressure systolic/diastolic so a single tile + * shows both numbers). */ + secondary?: SecondaryMetric; } export function TrendCard({ @@ -37,6 +48,7 @@ export function TrendCard({ avg30Hint, slope30, icon: Icon, + secondary, }: TrendCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -58,8 +70,19 @@ export function TrendCard({ const formatValue = (value: number) => fmt.number(value, 1); + const renderPair = ( + primary: number | null, + secondaryValue: number | null | undefined, + ): string => { + if (primary === null) return "—"; + if (secondary && secondaryValue !== null && secondaryValue !== undefined) { + return `${formatValue(primary)}/${formatValue(secondaryValue)}`; + } + return formatValue(primary); + }; + return ( -
+
{label} @@ -68,13 +91,15 @@ export function TrendCard({
- {latest !== null ? formatValue(latest) : "—"} + {latest !== null + ? renderPair(latest, secondary?.latest) + : "—"} {unit} {slope30 && }
-
+
{avg7 !== null && ( {t("charts.avg7dShort")}:{" "} @@ -82,7 +107,7 @@ export function TrendCard({ - {formatValue(avg7)} + {renderPair(avg7, secondary?.avg7)} @@ -91,7 +116,7 @@ export function TrendCard({ ) : ( - {formatValue(avg7)} + {renderPair(avg7, secondary?.avg7)} )} @@ -103,7 +128,7 @@ export function TrendCard({ - {formatValue(avg30)} + {renderPair(avg30, secondary?.avg30)} @@ -112,7 +137,7 @@ export function TrendCard({ ) : ( - {formatValue(avg30)} + {renderPair(avg30, secondary?.avg30)} )} diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 7039fd19f..2eb944cc2 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -122,11 +122,16 @@ describe("settings sections — SSR smoke", () => { expect(html).toContain("Notifications"); }); - it(" renders heading and embeds layout + thresholds", () => { + it(" renders heading and embeds the layout customizer", () => { + // v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved + // out into their own settings section under `/settings/thresholds`, + // so the dashboard panel is now layout-only. Verifying the heading + // says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the + // layout slot still mounts. const html = render(); expect(html).toContain("Dashboard"); expect(html).toContain('data-testid="dashboard-layout"'); - expect(html).toContain('data-testid="thresholds"'); + expect(html).not.toContain('data-testid="thresholds"'); }); it(" renders endpoints + tokens cards", () => { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 06c1a9fcc..eb5d257f3 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -35,14 +35,18 @@ function renderShell(props: { } describe("SETTINGS_SECTION_SLUGS", () => { - it("declares exactly the eight sections agreed for v1.4", () => { + it("declares exactly the nine sections agreed for v1.4.3", () => { // Order matters — `generateStaticParams()` and the sidebar derive their // ordering from this constant, so a reorder is a behaviour change. + // v1.4.3 split the dashboard panel: layout stays under `dashboard`, + // per-metric overrides moved to their own `thresholds` slug so it's + // a top-level entry in the settings nav. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", "notifications", "dashboard", + "thresholds", "ai", "api", "advanced", @@ -67,7 +71,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); describe("", () => { - it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => { + it("renders every section link — once for the mobile strip and once for the desktop sidebar", () => { const html = renderShell({ active: "account" }); for (const slug of SETTINGS_SECTION_SLUGS) { const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); @@ -138,7 +142,12 @@ describe("", () => { expect(html).toContain("Konto"); expect(html).toContain("Integrationen"); expect(html).toContain("Benachrichtigungen"); - expect(html).toContain("Übersicht"); + // v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now + // "Dashboard" (matching the term users see in the main nav). The + // per-metric overrides moved out into their own "Persönliche Zielwerte" + // section, which is the new entry below the Dashboard one. + expect(html).toContain("Dashboard"); + expect(html).toContain("Persönliche Zielwerte"); expect(html).toContain("KI-Auswertungen"); // API & Tokens is identical in both locales (proper noun + ampersand) expect(html).toContain("API & Tokens"); diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx index 90a8b1728..6914aaaac 100644 --- a/src/components/settings/dashboard-section.tsx +++ b/src/components/settings/dashboard-section.tsx @@ -1,7 +1,6 @@ "use client"; import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section"; -import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { useTranslations } from "@/lib/i18n/context"; export function DashboardSection() { @@ -25,7 +24,6 @@ export function DashboardSection() { - ); } diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 5aff0d212..7e63de08c 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -18,6 +18,7 @@ import { LayoutDashboard, Link2, Settings2, + SlidersHorizontal, Sparkles, User, type LucideIcon, @@ -32,6 +33,7 @@ const SLUG_ICON: Record = { integrations: Link2, notifications: Bell, dashboard: LayoutDashboard, + thresholds: SlidersHorizontal, ai: Sparkles, api: KeyRound, advanced: Settings2, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 7198be4ea..5a69cbf0f 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -11,6 +11,7 @@ export const SETTINGS_SECTION_SLUGS = [ "integrations", "notifications", "dashboard", + "thresholds", "ai", "api", "advanced", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index d329e3bbd..6434d543b 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -24,6 +24,7 @@ import { LayoutDashboard, Link2, Settings2, + SlidersHorizontal, Sparkles, User, type LucideIcon, @@ -79,6 +80,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.dashboard.title", icon: LayoutDashboard, }, + { + slug: "thresholds", + titleKey: "settings.sections.thresholds.title", + icon: SlidersHorizontal, + }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, { diff --git a/src/components/settings/thresholds-settings-section.tsx b/src/components/settings/thresholds-settings-section.tsx new file mode 100644 index 000000000..e55f745da --- /dev/null +++ b/src/components/settings/thresholds-settings-section.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ThresholdsSection } from "@/components/settings/thresholds-section"; +import { useTranslations } from "@/lib/i18n/context"; + +export function ThresholdsSettingsSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.thresholds.title")} +

+

+ {t("settings.sections.thresholds.description")} +

+
+ + +
+ ); +}