Merge branch 'fix/v1473-record' into release/v1.47.3

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:18:59 +01:00
commit f519df4433
84 files changed
+1368 -322

No files matched your search

@@ -42,11 +42,10 @@ const FULL_RECORD = {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "shared-account",
name: "A. Patient",
dateOfBirth: "1980-01-01T00:00:00.000Z",
gender: "FEMALE",
heightCm: 170,
fullName: "A. Patient",
},
practiceName: null,
measurements: {},
@@ -27,7 +27,7 @@ function makeReport(
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "tester",
name: "tester",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -72,6 +72,7 @@ function render(
| "selection"
| "timeFormat"
| "dateFormat"
| "timezone"
>
>,
) {
@@ -501,6 +502,45 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain('<img src="/c/hls_tok/d/doc-b"');
expect(html).toContain("Referral letter");
});
it("prints the date of birth on its own day west of UTC", () => {
const html = render(
makeReport({
patient: {
name: "A. Patient",
// Stored at UTC midnight: 13 May, 20:00 in New York.
dateOfBirth: "1960-05-14T00:00:00.000Z",
gender: null,
heightCm: null,
},
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("05/14/1960");
expect(html).not.toContain("05/13/1960");
});
it("dates a vaccination on its own day west of UTC", () => {
const html = render(
makeReport({
immunizations: [
{
// A dose date at UTC midnight: the evening before in New York.
occurredAt: "2026-04-11T00:00:00.000Z",
antigenSlug: null,
vaccineName: "Influenza",
lotNumber: null,
site: null,
practitionerName: null,
series: [],
},
],
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("04/11/2026");
expect(html).not.toContain("04/10/2026");
});
});
/**
+12 -2
View File
@@ -30,6 +30,7 @@
* document list in `./documents-list`, the downloads in `./download-actions`.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { formatDate } from "@/lib/date-format";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
import {
makeFormatters,
@@ -133,6 +134,15 @@ interface ClinicianViewProps {
dateFormat: DateFormatPreference;
}
/**
* A date-only value (a date of birth, a dose date) as its calendar date in
* the owner's date order, the same in every zone. Read as an instant in the
* owner's zone it fell on the previous day west of UTC.
*/
function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) {
return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale);
}
export function ClinicianView({
t,
label,
@@ -199,7 +209,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
{/* ── measurements ─────────────────────────────────────── */}
@@ -298,7 +308,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
<AllergiesSection t={t} report={report} scope={scope} />
@@ -302,12 +302,13 @@ export function ImmunizationsSection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
/** A dose date is a calendar date at UTC midnight, never an instant. */
fmtCalendarDate: (iso: string) => string;
}) {
const doses = report.immunizations ?? [];
@@ -342,7 +343,7 @@ export function ImmunizationsSection({
: (dose.vaccineName ?? "—")
}
value={compose([
fmtDate(dose.occurredAt),
fmtCalendarDate(dose.occurredAt),
doseDisplay(dose.series),
dose.lotNumber
? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}`
@@ -48,15 +48,15 @@ export function PatientIdentitySection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
fmtCalendarDate: (iso: string) => string;
}) {
const patient = report.patient;
const name = patient.fullName ?? patient.username ?? null;
const name = patient.name;
const genderKey = patient.gender
? GENDER_LABEL_KEYS[patient.gender]
: undefined;
@@ -67,7 +67,7 @@ export function PatientIdentitySection({
if (patient.dateOfBirth) {
rows.push({
label: t("doctorReport.dateOfBirth"),
value: fmtDate(patient.dateOfBirth),
value: fmtCalendarDate(patient.dateOfBirth),
});
}
if (genderKey) {
@@ -42,7 +42,11 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => "2026-07-01",
useCycleWindow: () => ({
today: "2026-07-01",
from: "2026-04-02",
to: "2026-12-28",
}),
useCycleCalendar: () => errorQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -95,7 +95,7 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => TODAY,
useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }),
useCycleCalendar: () => calendarQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { localYmd, useCycleRingDial } from "../use-cycle";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { useCycleRingDial } from "../use-cycle";
/** The ring's window, around today in the display zone (Berlin when unset). */
function window(): [string, string] {
const from = new Date();
from.setDate(from.getDate() - 90);
const to = new Date();
to.setDate(to.getDate() + 180);
return [localYmd(from), localYmd(to)];
const today = userDayKey(new Date(), DEFAULT_TIMEZONE);
return [shiftDateKey(today, -90), shiftDateKey(today, 180)];
}
function Probe({ enabled }: { enabled: boolean }) {
@@ -0,0 +1,33 @@
/**
* The cycle surfaces take "today" from the record's display zone, the zone
* the server keys cycle days in. They used to read the device clock, so with
* a phone in another zone than the profile the highlighted day and the day a
* log lands on sat one apart from the server's around midnight.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
// UTC+14: already the next calendar day for most of the world's afternoon.
useDisplayTimezone: () => "Pacific/Kiritimati",
}));
import { useCycleWindow } from "../use-cycle";
function Probe() {
const { today, from, to } = useCycleWindow();
return <span>{`${from}|${today}|${to}`}</span>;
}
afterEach(() => vi.useRealTimers());
describe("useCycleWindow", () => {
it("dates today in the display zone, not on the device clock", () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
expect(renderToStaticMarkup(<Probe />)).toContain(
"2026-04-03|2026-07-02|2026-12-29",
);
});
});
@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { ArrowRight, Droplets } from "lucide-react";
@@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import {
useCycleCalendar,
useCycleInsights,
useCycleWindow,
} from "./use-cycle";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
@@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
* a broken or empty-framed state on the overview.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
export function CycleInsightSummaryCard() {
const { t } = useTranslations();
// Mirror the cycle-view window so the cache key is shared (no extra request
// when the user later opens the cycle page in the same session).
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const insights = useCycleInsights();
+2 -11
View File
@@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
localYmd,
useCycleWindow,
useCycleCalendar,
useCycleHistory,
useCycleInsights,
@@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice";
* re-triggers the moment.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
// The BBT chart is the only recharts consumer on /cycle — load it through
@@ -114,9 +107,7 @@ export function CycleView() {
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState<CycleTab>(initialTab);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { today, from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const history = useCycleHistory();
+20 -23
View File
@@ -21,7 +21,8 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
@@ -72,16 +73,23 @@ export interface CycleInsightsResponse {
}
/**
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
* are tz-anchored on the server; the client must derive the key from the
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
* the date over near midnight).
* Today, and the window the cycle page, its ring and its summary card read
* (90 days back, 180 ahead), in the record's display zone. The server keys
* cycle days, "today" included, in the profile zone; the device clock put
* the highlighted day and the day a log lands on one apart from it when a
* phone sat in another zone near midnight. One window, so the three readers
* share one cache entry.
*/
export function localYmd(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
export function useCycleWindow(): { today: string; from: string; to: string } {
const timeZone = useDisplayTimezone();
return useMemo(() => {
const today = userDayKey(new Date(), timeZone);
return {
today,
from: shiftDateKey(today, -90),
to: shiftDateKey(today, 180),
};
}, [timeZone]);
}
/**
@@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
});
}
/** YYYY-MM-DD for `days` from today in the local zone. */
function localYmdFromToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/**
* The calendar window the cycle ring reads: the cycle page's own window, so
* the ring, the page and the summary card share one cache entry.
*/
/** The calendar window the cycle ring reads: the cycle page's own. */
export function useCycleRingCalendar(enabled = true) {
const from = useMemo(() => localYmdFromToday(-90), []);
const to = useMemo(() => localYmdFromToday(180), []);
const { from, to } = useCycleWindow();
return useCycleCalendar(from, to, enabled);
}
@@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -161,6 +162,7 @@ export function DocumentRecordLinks({
}) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const { user } = useAuth();
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
@@ -186,10 +188,7 @@ export function DocumentRecordLinks({
const [visitIds, setVisitIds] = useState<string[] | null>(null);
const [doseIds, setDoseIds] = useState<string[] | null>(null);
const labels = {
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
};
const suggested = t("links.picker.suggested");
const visitOptions = recordOptions(
[...encounters.items]
@@ -203,7 +202,7 @@ export function DocumentRecordLinks({
href: visitHref(visit.id),
})),
anchor,
labels,
{ suggested, date: format.date },
);
const doseOptions = recordOptions(
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
@@ -213,7 +212,7 @@ export function DocumentRecordLinks({
href: doseHref(dose.id),
})),
anchor,
labels,
{ suggested, date: calendarDate }, // a dose date is a calendar date
);
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
@@ -324,7 +323,7 @@ export function DocumentRecordLinks({
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
{calendarDate(link.occurredAt)}
</span>
) : null}
</Link>
@@ -0,0 +1,60 @@
/**
* The card prints a lab figure the way the labs page does: in the reader's
* notation and with the digits the reading holds. It used to print
* `String(value)`, so a German reader saw "4.6" beside a labs page that says
* "4,6", and a converted reading showed every digit of the conversion.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
present: true,
latestDate: "2026-06-01",
previousDate: "2026-03-01",
generatedAt: "2026-06-02T00:00:00.000Z",
changes: [
{
analyte: "TSH",
unit: "mIU/L",
latest: 0.009,
previous: 0.004,
delta: 0.005,
direction: "up",
status: "unknown",
},
{
analyte: "Potassium",
unit: "mmol/L",
latest: 4.6,
previous: 4.9,
delta: -0.3,
direction: "down",
status: "unknown",
},
],
},
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { LabsChangesCard } from "../labs-changes-card";
describe("<LabsChangesCard>", () => {
it("prints values and deltas in the reader's notation", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<LabsChangesCard />
</I18nProvider>,
);
expect(html).toContain("0,009 mIU/L");
expect(html).toContain("+0,005 mIU/L");
expect(html).toContain("4,6 mmol/L");
expect(html).toContain("-0,3 mmol/L");
});
});
@@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/insights/weight",
}));
// The target gear asks whether this is somebody else's record; the shell
// renders here without the session it would read that from.
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
/**
* v1.4.25 — `<SubPageShell>` unit tests.
*
@@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({
useTargetAdjust: () => useTargetAdjustMock(),
}));
const capabilities = { inSharedRecord: false };
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => capabilities,
}));
const { TargetAdjustButton } = await import("../target-adjust-button");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -53,6 +58,22 @@ describe("<TargetAdjustButton>", () => {
expect(html).toBe("");
});
it("renders nothing inside somebody else's record", () => {
// The editor reads and writes the signed-in account's own targets, which
// the server refuses under a switch: the gear would open a dead sheet.
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
requestAdjust: vi.fn(),
register: vi.fn(),
});
capabilities.inSharedRecord = true;
try {
expect(render(<TargetAdjustButton />)).toBe("");
} finally {
capabilities.inSharedRecord = false;
}
});
it("renders the gear once an editable target is registered", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
@@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
import { withLabUnit } from "@/lib/labs/dimensionless";
import { useLabNumber } from "@/components/labs/use-lab-format";
/**
* v1.25 — "what changed since your last panel" card.
@@ -55,6 +56,7 @@ export function LabsChangesCard({
}: LabsChangesCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const labNumber = useLabNumber();
const { data } = useQuery({
queryKey: queryKeys.insightsLabsChanges(),
@@ -82,7 +84,10 @@ export function LabsChangesCard({
const signedDelta =
c.direction === "flat"
? t("insights.labsChanges.directionFlat")
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
: withLabUnit(
`${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`,
c.unit,
);
return (
<div
key={c.analyte}
@@ -94,7 +99,7 @@ export function LabsChangesCard({
{c.analyte}
</p>
<p className="text-muted-foreground text-xs">
{withLabUnit(String(c.latest), c.unit)}
{withLabUnit(labNumber(c.latest), c.unit)}
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
/**
* Header gear that opens the per-metric target editor.
@@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
* has registered for the page (e.g. a metric without a numeric
* target band, where the card paints nothing), so the header never
* shows a dead control;
* - renders nothing inside somebody else's record: the editor loads and
* saves the signed-in account's own targets (`/api/user/thresholds`),
* which refuses under a switch, so the gear would open a sheet that
* can do neither;
* - opens the primary registered target's `<TargetEditSheet>` on click.
*
* Sized to match `<CoachLaunchButton variant="icon">` so the gear + the
@@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
export function TargetAdjustButton({ className }: { className?: string }) {
const { t } = useTranslations();
const adjust = useTargetAdjust();
const { inSharedRecord } = useRecordCapabilities();
// No provider, or no editable target registered for this page.
if (!adjust || !adjust.canAdjust) return null;
// No provider, no editable target registered for this page, or a record
// whose targets this editor cannot reach.
if (!adjust || !adjust.canAdjust || inSharedRecord) return null;
// Reuse the action's existing copy ("Adjust target range" /
// "Zielbereich anpassen") — the gear performs exactly what the old
+4 -12
View File
@@ -40,7 +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 { accountName, nameInitials } from "@/lib/account-name";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
@@ -98,14 +98,6 @@ export function writeSidebarCollapsedPref(collapsed: boolean): void {
}
}
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
/**
* The sidebar footer's name-and-avatar block.
*
@@ -170,7 +162,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
+2 -10
View File
@@ -26,7 +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 { accountName, nameInitials } from "@/lib/account-name";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
@@ -41,14 +41,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function TopBar() {
const { user, isLoading } = useAuth();
// v1.36.0 — acting on somebody else's record; see the menu below. Read from
@@ -147,7 +139,7 @@ export function TopBar() {
<AvatarImage src={avatarUrl} alt={accountName(user)} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(accountName(user))}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{accountName(user)}</span>
+10 -6
View File
@@ -37,6 +37,7 @@ import {
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
pickerPivot,
useLinkPickerPages,
@@ -133,6 +134,7 @@ export function RelatedRecordsList({
}) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind));
if (rows.length === 0) return null;
return (
@@ -142,7 +144,12 @@ export function RelatedRecordsList({
const via = viaText(t, row.via);
const meta = [
kindText(t, row.kind),
row.date ? format.date(row.date) : null,
row.date
? // A dose date is a calendar date at UTC midnight.
row.kind === "vaccination"
? calendarDate(row.date)
: format.date(row.date)
: null,
via,
]
.filter(Boolean)
@@ -381,7 +388,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) {
function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const doses = useVaccinations(null, true);
const options = recordOptions(
(doses.data?.vaccinations ?? []).map((dose) => ({
@@ -393,10 +400,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`,
})),
anchor,
{
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
},
{ suggested: t("links.picker.suggested"), date: calendarDate },
);
return (
<EntityLinkPicker
@@ -0,0 +1,39 @@
/**
* The avatar fallback on the settings card spells the name the rest of the
* app shows. It used to take its letters from the login, so an account named
* "Robin Example" with the login "rex" read "RE" in the top bar and the
* sidebar and "R" here.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "rex",
displayName: "Robin Example",
avatarUrl: null,
},
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AvatarSection } from "../avatar-section";
describe("the settings avatar fallback", () => {
it("builds its initials from the display name, not the login", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AvatarSection />
</I18nProvider>,
);
expect(html).toContain(">RE<");
expect(html).not.toContain(">R<");
});
});
@@ -13,6 +13,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { statusText, type StatusMessage } from "./account-section-utils";
import { accountName, nameInitials } from "@/lib/account-name";
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
@@ -21,14 +22,6 @@ import { statusText, type StatusMessage } from "./account-section-utils";
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
@@ -123,9 +116,9 @@ export function AvatarSection() {
</p>
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={accountName(user)} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">