mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
58e9d1451a
commit
8c0593fe79
12 files changed
+144
-75
No files matched your search
+6
-2
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+42
-58
@@ -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: (
|
||||
<React.Fragment key="bp">
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<TrendCard
|
||||
key="bp"
|
||||
label={t("dashboard.bloodPressure")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
secondary={{
|
||||
latest: dia?.latest ?? null,
|
||||
avg7: dia?.avg7 ?? null,
|
||||
avg30: dia?.avg30 ?? null,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -868,14 +852,14 @@ export default function DashboardPage() {
|
||||
* touch.
|
||||
*/}
|
||||
<div
|
||||
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
|
||||
className="-mx-2 flex snap-x snap-mandatory items-stretch gap-3 overflow-x-auto px-2 pb-2 md:mx-0 md:px-0"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="shrink-0 grow basis-[10rem] snap-start"
|
||||
className="flex shrink-0 grow basis-[10rem] snap-start"
|
||||
>
|
||||
{entry.node}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-card border-border rounded-xl border p-3">
|
||||
<div className="bg-card border-border flex h-full flex-col rounded-xl border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
{label}
|
||||
@@ -68,13 +91,15 @@ export function TrendCard({
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
{latest !== null ? formatValue(latest) : "—"}
|
||||
{latest !== null
|
||||
? renderPair(latest, secondary?.latest)
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{unit}</span>
|
||||
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="text-muted-foreground mt-1 flex gap-3 text-xs">
|
||||
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
|
||||
{avg7 !== null && (
|
||||
<span>
|
||||
{t("charts.avg7dShort")}:{" "}
|
||||
@@ -82,7 +107,7 @@ export function TrendCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-medium", avg7ColorClass)}>
|
||||
{formatValue(avg7)}
|
||||
{renderPair(avg7, secondary?.avg7)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
@@ -91,7 +116,7 @@ export function TrendCard({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("font-medium", avg7ColorClass)}>
|
||||
{formatValue(avg7)}
|
||||
{renderPair(avg7, secondary?.avg7)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -103,7 +128,7 @@ export function TrendCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-medium", avg30ColorClass)}>
|
||||
{formatValue(avg30)}
|
||||
{renderPair(avg30, secondary?.avg30)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
@@ -112,7 +137,7 @@ export function TrendCard({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("font-medium", avg30ColorClass)}>
|
||||
{formatValue(avg30)}
|
||||
{renderPair(avg30, secondary?.avg30)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -122,11 +122,16 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).toContain("Notifications");
|
||||
});
|
||||
|
||||
it("<DashboardSection> renders heading and embeds layout + thresholds", () => {
|
||||
it("<DashboardSection> 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(<DashboardSection />);
|
||||
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("<ApiSection> renders endpoints + tokens cards", () => {
|
||||
|
||||
@@ -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("<SettingsShell>", () => {
|
||||
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("<SettingsShell>", () => {
|
||||
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");
|
||||
|
||||
@@ -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() {
|
||||
</header>
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
@@ -32,6 +33,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
integrations: Link2,
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
thresholds: SlidersHorizontal,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
advanced: Settings2,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"thresholds",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
aria-labelledby="settings-section-thresholds-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-thresholds-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.thresholds.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.thresholds.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user