mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'fix/v1473-record' into release/v1.47.3
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user