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
This commit is contained in:
Marc-André Bombeck committed 2026-11-01 00:56:24 +01:00
1 parent 022c6de0b5
commit 1c4bcce6c7
7 files changed
+73 -17

No files matched your search

+3 -3
View File
@@ -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.
@@ -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);
@@ -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("<SidebarNav> 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</p>");
expect(html).toContain(">JD</span>");
expect(html).not.toContain(">testuser</p>");
});
it("falls back to the username without a display name", () => {
const html = render();
expect(html).toContain(">testuser</p>");
});
});
describe("<SidebarNav> first-paint fail-closed module gate (v1.25.12)", () => {
it("renders core destinations on first paint regardless of the module map", () => {
const maps: Record<string, boolean>[] = [
+10 -10
View File
@@ -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 = (
<>
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.username}</p>
<p className="truncate text-sm font-semibold">{name}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">{user.email}</p>
)}
@@ -282,11 +284,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<DropdownMenuTrigger className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]">
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -311,13 +311,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
label={t("nav.accountSettings")}
>
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.username}</p>
<p className="truncate text-sm font-medium">{name}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
+4 -3
View File
@@ -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() {
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="size-7" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
<AvatarImage src={avatarUrl} alt={accountName(user)} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{user.username}</span>
<span className="hidden sm:inline">{accountName(user)}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
+21
View File
@@ -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");
});
});
+13
View File
@@ -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;
}