From 1c4bcce6c78705ffadcdf20ede68020e695d44a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 1 Nov 2026 00:56:24 +0100 Subject: [PATCH] fix(ui): name the account by its display name The dashboard greeting, the Insights greeting, the top bar and the sidebar read the username, so a display name set on the profile card reached the reports but none of the surfaces that address the account. They now read one helper that returns the display name when set and falls back to the username otherwise. Refs #1309 --- src/app/insights/page-client.tsx | 6 +++--- src/components/dashboard/dashboard-header.tsx | 3 ++- .../layout/__tests__/sidebar-nav.test.tsx | 20 ++++++++++++++++++ src/components/layout/sidebar-nav.tsx | 20 +++++++++--------- src/components/layout/top-bar.tsx | 7 ++++--- src/lib/__tests__/account-name.test.ts | 21 +++++++++++++++++++ src/lib/account-name.ts | 13 ++++++++++++ 7 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 src/lib/__tests__/account-name.test.ts create mode 100644 src/lib/account-name.ts diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index de0233ea3..fb3c162be 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; // Bare number — its own React- and label-map-free module, so importing it here @@ -419,10 +420,9 @@ export default function InsightsPageClient() { // boundary hydrates, and a greeting that personalises during the // hydration render disagrees with the name-less SSR text — React // #418. See `useMounted`. + const shownName = user ? accountName(user).trim() : ""; const heroGreetingName = - mounted && user?.username?.trim() && user.username.trim().length > 0 - ? user.username.split(/\s+/)[0] - : null; + mounted && shownName.length > 0 ? shownName.split(/\s+/)[0] : null; // The advisor read only fires while the briefing is available, so with it // unavailable these stay null: the hero keeps its fallback subtitle and the // trends row its built-in captions. diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 1a7308bff..c4e70a63b 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -32,6 +32,7 @@ import { import { PageHeader } from "@/components/ui/page-header"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; @@ -143,7 +144,7 @@ export function DashboardHeader({ const greeting = greetingLine({ greeting: timeGreeting, known: mounted && user != null, - name: user?.username?.trim() ? user.username : null, + name: user ? accountName(user).trim() || null : null, createdAt: user?.createdAt, }); const welcomeText = t(greeting.key, greeting.values); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index c6d519608..a79eda4d1 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -18,6 +18,7 @@ const mockUserRef = { value: { id: "u1", username: "testuser", + displayName: null as string | null, email: "user@example.com", role: "USER" as "USER" | "ADMIN", avatarUrl: null, @@ -119,6 +120,25 @@ const GATED_HREFS = [ "/achievements", ] as const; +describe(" account identity", () => { + afterEach(() => { + mockUserRef.value = { ...mockUserRef.value, displayName: null }; + }); + + it("names the account by its display name when one is set", () => { + mockUserRef.value = { ...mockUserRef.value, displayName: "Jane Doe" }; + const html = render(); + expect(html).toContain(">Jane Doe

"); + expect(html).toContain(">JD"); + expect(html).not.toContain(">testuser

"); + }); + + it("falls back to the username without a display name", () => { + const html = render(); + expect(html).toContain(">testuser

"); + }); +}); + describe(" first-paint fail-closed module gate (v1.25.12)", () => { it("renders core destinations on first paint regardless of the module map", () => { const maps: Record[] = [ diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index ff2c62444..28062cbb8 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -40,6 +40,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; @@ -153,6 +154,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const sharedRecord = user?.accountAccess?.active != null; if (!user) return null; + const name = accountName(user); const themeIcon = theme === "system" ? ( @@ -166,13 +168,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const identityBlock = ( <> - {avatarUrl && } + {avatarUrl && } - {getInitials(user.username)} + {getInitials(name)}
-

{user.username}

+

{name}

{user.email && (

{user.email}

)} @@ -282,11 +284,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {t("nav.userMenu")} @@ -311,13 +311,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { label={t("nav.accountSettings")} >
-

{user.username}

+

{name}

{user.email && (

{user.email} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 23988f100..4d499d1a2 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -26,6 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import Link from "next/link"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -143,13 +144,13 @@ export function TopBar() { {t("nav.userMenu")} - {user.username} + {accountName(user)} diff --git a/src/lib/__tests__/account-name.test.ts b/src/lib/__tests__/account-name.test.ts new file mode 100644 index 000000000..3d080ebb1 --- /dev/null +++ b/src/lib/__tests__/account-name.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { accountName } from "../account-name"; + +describe("accountName", () => { + it("names the account by its display name when one is set", () => { + expect(accountName({ username: "jdoe", displayName: "Jane" })).toBe("Jane"); + }); + + it("trims the display name it shows", () => { + expect(accountName({ username: "jdoe", displayName: " Jane " })).toBe( + "Jane", + ); + }); + + it("falls back to the username when the display name is empty or absent", () => { + expect(accountName({ username: "jdoe", displayName: " " })).toBe("jdoe"); + expect(accountName({ username: "jdoe", displayName: null })).toBe("jdoe"); + expect(accountName({ username: "jdoe" })).toBe("jdoe"); + }); +}); diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts new file mode 100644 index 000000000..2340acaa6 --- /dev/null +++ b/src/lib/account-name.ts @@ -0,0 +1,13 @@ +/** + * The name the app shows and addresses an account by: the display name when + * one is set, the username otherwise. Greetings, the top bar and the sidebar + * all read it here, so the display name edited on the profile card reaches + * every surface that names the account. + */ +export function accountName(user: { + username: string; + displayName?: string | null; +}): string { + const displayName = user.displayName?.trim(); + return displayName ? displayName : user.username; +}