mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness audit (the two server-query findings — comprehensive-generate + boot-discovery distinct — are deferred to a careful separate perf release): - format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call (main-thread stall across every history list). Routed through the existing process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle in the key); output byte-identical. - measurement-list rendered every row twice (desktop table + mobile cards, one CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe useIsMobile(md) hook; row markup + every data-slot unchanged. - moodDependentKeys omitted dashboardSnapshot(), so a mood write left the dashboard tile/score stale ~120s (server cache was already correct). Added it. - register first-user admin was a count-then-create race (two concurrent first sign-ups could both become ADMIN). Now an advisory lock + in-transaction re-count makes first-admin atomic; P2002 maps to 409 instead of 500. Regression test per fix. No migration (0243 still free).
This commit is contained in:
1 parent
9569d1c528
commit
d4be568625
13 files changed
+708
-426
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.42] — 2026-07-16 — Snappier lists, correct mood refresh, safer first sign-up
|
||||
|
||||
Performance and correctness fixes:
|
||||
|
||||
- The shared date/number formatters rebuilt a fresh locale formatter on every
|
||||
single call, which stalled the main thread across long history lists. They
|
||||
now reuse cached formatters — the same output, without the per-row cost.
|
||||
- Long measurement lists (a year of dense readings) rendered every row twice —
|
||||
a desktop and a mobile copy, one hidden with CSS. They now render one layout
|
||||
for the current viewport.
|
||||
- Logging a mood entry now refreshes the dashboard immediately; the mood tile
|
||||
and score could previously stay stale for up to two minutes after a change.
|
||||
- On a brand-new instance, two people signing up at the exact same moment could
|
||||
both be made admin. First-admin selection is now atomic, so exactly one does.
|
||||
|
||||
## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap
|
||||
|
||||
One reminder-adjacent label — the recovery-driver "functional impact" track on
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.41
|
||||
version: 1.28.42
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.41",
|
||||
"version": "1.28.42",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.41";
|
||||
/* @sw-version-fallback */ "v1.28.42";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -14,9 +14,23 @@ vi.mock("@/lib/db", () => ({
|
||||
appSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }),
|
||||
},
|
||||
// v1.28.42 (M1) — the first-admin count+insert now runs inside a
|
||||
// transaction-scoped advisory lock. Default `$transaction` to run the
|
||||
// callback with the same prisma mock as its `tx` handle.
|
||||
$transaction: vi.fn(),
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ locked: 1 }]),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.28.42 — fail-open breach check + silent device record; mock so the
|
||||
// happy-path register tests don't reach the network / a real DB write.
|
||||
vi.mock("@/lib/auth/hibp", () => ({
|
||||
checkPasswordBreach: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -62,6 +76,10 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/register", {
|
||||
@@ -119,6 +137,90 @@ async function postPathThrough(body: unknown): Promise<Response> {
|
||||
return POST(postReq(body));
|
||||
}
|
||||
|
||||
// v1.28.42 (M1 + L4) — first-admin race + duplicate-under-race status code.
|
||||
describe("POST /api/auth/register — first-admin atomicity (M1)", () => {
|
||||
beforeEach(() => {
|
||||
// Happy-path setup: reach the create call deterministically.
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("hashed");
|
||||
vi.mocked(createSession).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.create).mockImplementation((async (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => ({
|
||||
id: "u1",
|
||||
username: args.data.username,
|
||||
email: args.data.email,
|
||||
role: args.data.role,
|
||||
})) as never);
|
||||
// Run the transaction callback against the same prisma mock as `tx`.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
});
|
||||
|
||||
const validBody = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
};
|
||||
|
||||
it("mints ADMIN when the in-transaction count is 0 (genuine first user)", async () => {
|
||||
// Early gate count and in-tx count both 0.
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("mints USER when a concurrent registration committed first (stale early read, fresh in-tx re-count)", async () => {
|
||||
// The early gate read observed the empty-DB window (0), but by the time
|
||||
// the locked transaction re-counts, the racing first registration has
|
||||
// committed → 1. Without the in-tx re-count this second user would also
|
||||
// be minted ADMIN (the double-admin bug); the fresh count closes it.
|
||||
vi.mocked(prisma.user.count)
|
||||
.mockResolvedValueOnce(0 as never) // early registrationEnabled gate
|
||||
.mockResolvedValueOnce(1 as never); // inside the advisory-locked tx
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("acquires the transaction-scoped advisory lock before counting", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
|
||||
await postPathThrough(validBody);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a P2002 duplicate under the race to 409, not 500 (L4)", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Username or email already taken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
consumeInviteToken,
|
||||
recordInviteConsumer,
|
||||
@@ -179,19 +180,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// First user becomes admin
|
||||
const role = userCount === 0 ? "ADMIN" : "USER";
|
||||
// v1.28.42 (M1) — the first registered user becomes ADMIN. Deriving that
|
||||
// role from the early `userCount` (read at the top, before all the
|
||||
// validation above) and then creating without coordination is a
|
||||
// check-then-act race: on a freshly-exposed instance two registrations
|
||||
// racing the empty-DB window both observe `0` and are BOTH minted ADMIN.
|
||||
// Serialise the count+insert behind a transaction-scoped advisory lock
|
||||
// (released on commit/rollback) and re-count *inside* the lock, so the
|
||||
// second registration always observes the first's committed row and is
|
||||
// minted USER. The lock scope is only the count + insert — none of the
|
||||
// argon2 hash / HIBP / invite work above runs under it. Mirrors the
|
||||
// advisory-lock single-flight in `drain-per-sample-cumulative.ts`.
|
||||
//
|
||||
// v1.28.42 (L4) — a concurrent same-email/username signup (both passed the
|
||||
// findUnique probe above before either committed) surfaces the unique
|
||||
// violation as `P2002`; map it to the unified 409 rather than an unhandled
|
||||
// 500. The DB unique index is the real guard against duplicate accounts;
|
||||
// this only fixes the status code under the race.
|
||||
const user = await prisma
|
||||
.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column — selecting FROM it yields a plain int row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
|
||||
`;
|
||||
const priorUsers = await tx.user.count();
|
||||
const role = priorUsers === 0 ? "ADMIN" : "USER";
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return apiError("Username or email already taken", 409);
|
||||
}
|
||||
|
||||
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
|
||||
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
|
||||
|
||||
@@ -52,8 +52,16 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.28.42 (H3) — the list now mounts only the active layout (desktop table
|
||||
// OR mobile card list), not both CSS-hidden copies. Drive the breakpoint hook
|
||||
// so each branch can be asserted independently.
|
||||
vi.mock("@/hooks/use-is-mobile", () => ({
|
||||
useIsMobile: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -86,15 +94,26 @@ describe("MeasurementList — APPLE_HEALTH badge", () => {
|
||||
expect(html).toContain("Apple Health");
|
||||
});
|
||||
|
||||
it("paints the source badge in BOTH the desktop table and the mobile card", () => {
|
||||
it("paints the source badge in EACH layout (desktop table + mobile card)", () => {
|
||||
// v1.4.23 W6 design review HIGH-1: the badge previously rendered
|
||||
// only inside the desktop `<Table>` cell. iOS users (the audience
|
||||
// that actually consumes Apple Health data) land on the mobile
|
||||
// card branch and saw no source indicator at all, risking
|
||||
// duplicate hand-entries on top of synced rows. Both branches must
|
||||
// duplicate hand-entries on top of synced rows. Both layouts must
|
||||
// carry the badge so the surface is honest at every viewport.
|
||||
const html = render("en");
|
||||
const matches = html.match(/data-testid="measurement-source-badge"/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
//
|
||||
// v1.28.42 (H3) — only the active layout mounts now, so assert the
|
||||
// badge appears once in each branch (rather than twice in one render).
|
||||
vi.mocked(useIsMobile).mockReturnValue(false);
|
||||
const desktop = render("en").match(
|
||||
/data-testid="measurement-source-badge"/g,
|
||||
);
|
||||
expect(desktop?.length ?? 0).toBe(1);
|
||||
|
||||
vi.mocked(useIsMobile).mockReturnValue(true);
|
||||
const mobile = render("en").match(
|
||||
/data-testid="measurement-source-badge"/g,
|
||||
);
|
||||
expect(mobile?.length ?? 0).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,15 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.28.42 (H3) — the list now mounts only the active layout, so drive the
|
||||
// breakpoint hook to assert the badge in each branch independently.
|
||||
vi.mock("@/hooks/use-is-mobile", () => ({
|
||||
useIsMobile: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -87,23 +94,40 @@ function countOccurrences(haystack: string, needle: string): number {
|
||||
}
|
||||
|
||||
describe("MeasurementList — Sys/Dia disambiguation badge (mobile list)", () => {
|
||||
// Both desktop table + mobile card list render in static markup
|
||||
// (responsive switching is purely CSS), so each label must appear
|
||||
// twice — once per branch — when the badge is wired correctly.
|
||||
// v1.28.42 (H3) — only the active layout mounts now (previously both the
|
||||
// desktop table and the mobile card list rendered, CSS-hidden). Assert each
|
||||
// label appears exactly once per branch so the mobile-enum regression this
|
||||
// guard was written for (badge silently absent on mobile) still fails loudly.
|
||||
|
||||
it("renders the Sys badge in BOTH desktop table and mobile list", () => {
|
||||
const html = render("en");
|
||||
expect(countOccurrences(html, ">Sys<")).toBe(2);
|
||||
it("renders the Sys badge in the desktop table", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(false);
|
||||
expect(countOccurrences(render("en"), ">Sys<")).toBe(1);
|
||||
});
|
||||
|
||||
it("renders the Dia badge in BOTH desktop table and mobile list", () => {
|
||||
const html = render("en");
|
||||
expect(countOccurrences(html, ">Dia<")).toBe(2);
|
||||
it("renders the Sys badge in the mobile list", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(true);
|
||||
expect(countOccurrences(render("en"), ">Sys<")).toBe(1);
|
||||
});
|
||||
|
||||
it("renders Sys + Dia in both branches under the German locale too", () => {
|
||||
const html = render("de");
|
||||
expect(countOccurrences(html, ">Sys<")).toBe(2);
|
||||
expect(countOccurrences(html, ">Dia<")).toBe(2);
|
||||
it("renders the Dia badge in the desktop table", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(false);
|
||||
expect(countOccurrences(render("en"), ">Dia<")).toBe(1);
|
||||
});
|
||||
|
||||
it("renders the Dia badge in the mobile list", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(true);
|
||||
expect(countOccurrences(render("en"), ">Dia<")).toBe(1);
|
||||
});
|
||||
|
||||
it("renders Sys + Dia in each branch under the German locale too", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(false);
|
||||
const desktop = render("de");
|
||||
expect(countOccurrences(desktop, ">Sys<")).toBe(1);
|
||||
expect(countOccurrences(desktop, ">Dia<")).toBe(1);
|
||||
|
||||
vi.mocked(useIsMobile).mockReturnValue(true);
|
||||
const mobile = render("de");
|
||||
expect(countOccurrences(mobile, ">Sys<")).toBe(1);
|
||||
expect(countOccurrences(mobile, ">Dia<")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -288,6 +289,16 @@ export function MeasurementList({
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
|
||||
// rows) or sleep page built every row subtree twice — double the DOM, double
|
||||
// the per-row `Intl` work. Gate on the SSR-safe `useIsMobile` so only the
|
||||
// active layout mounts. The hook resolves to the desktop branch on the server
|
||||
// and the first client paint (matching the SSR HTML → no hydration
|
||||
// mismatch), then flips to the live viewport after hydration; the `md:*`
|
||||
// visibility classes below stay as a belt-and-suspenders visual guard for
|
||||
// that first frame. No row markup or data-slot changes.
|
||||
const isMobile = useIsMobile("md");
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
// v1.22 — clicking a row's type badge drills into that metric's Insights
|
||||
@@ -880,119 +891,120 @@ export function MeasurementList({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
|
||||
{!isMobile && (
|
||||
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-28">
|
||||
{t("measurements.type")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("measurements.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-28">
|
||||
{t("measurements.type")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("measurements.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="measuredAt"
|
||||
label={t("measurements.date")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-56">
|
||||
{t("measurements.comment")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="source"
|
||||
label={t("measurements.source")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
className="w-20"
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
{t("measurements.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.measurements.map((m) => {
|
||||
const isGrouped =
|
||||
m.dayKey !== undefined && m.sampleCount !== undefined;
|
||||
// v1.11.5 — sleep night rows are grouped (chevron
|
||||
// drills into the stage segments) but render a
|
||||
// sleep-aware headline + nap caption, not the
|
||||
// cumulative "daily total" caption.
|
||||
const isSleep = m.type === "SLEEP_DURATION";
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — stable drill-down id so the
|
||||
// disclosure chevron can thread aria-controls to the
|
||||
// expanded panel. dayKey is unique per row when
|
||||
// grouped; fall back to m.id otherwise.
|
||||
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
<TableRow
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
{/* Grouped/synthetic rows aren't individually
|
||||
<SortableHead
|
||||
column="measuredAt"
|
||||
label={t("measurements.date")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-56">
|
||||
{t("measurements.comment")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="source"
|
||||
label={t("measurements.source")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
className="w-20"
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
{t("measurements.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.measurements.map((m) => {
|
||||
const isGrouped =
|
||||
m.dayKey !== undefined && m.sampleCount !== undefined;
|
||||
// v1.11.5 — sleep night rows are grouped (chevron
|
||||
// drills into the stage segments) but render a
|
||||
// sleep-aware headline + nap caption, not the
|
||||
// cumulative "daily total" caption.
|
||||
const isSleep = m.type === "SLEEP_DURATION";
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — stable drill-down id so the
|
||||
// disclosure chevron can thread aria-controls to the
|
||||
// expanded panel. dayKey is unique per row when
|
||||
// grouped; fall back to m.id otherwise.
|
||||
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
<TableRow
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
{/* Grouped/synthetic rows aren't individually
|
||||
deletable, so they have no checkbox. */}
|
||||
{!isGrouped && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
asChild
|
||||
variant="secondary"
|
||||
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t(
|
||||
"measurements.openMetricInsights",
|
||||
{
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
},
|
||||
)}
|
||||
{!isGrouped && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
asChild
|
||||
variant="secondary"
|
||||
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
</button>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{/* v1.4.39.3 — non-grouped rows render the
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t(
|
||||
"measurements.openMetricInsights",
|
||||
{
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
</button>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{/* v1.4.39.3 — non-grouped rows render the
|
||||
stored value with its native precision
|
||||
(`fmt.number` honours up to 3 fraction
|
||||
digits by default, drops trailing zeros)
|
||||
@@ -1004,33 +1016,33 @@ export function MeasurementList({
|
||||
integer-only by definition.
|
||||
v1.11.5 — sleep rows render the night's
|
||||
TIME ASLEEP as "8h 12m" + a nap caption. */}
|
||||
{isSleep ? (
|
||||
<>
|
||||
{formatSleepMinutes(m.value, locale)}
|
||||
<SleepNightCaption m={m} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(
|
||||
m.sampleCount as number,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
{/*
|
||||
{isSleep ? (
|
||||
<>
|
||||
{formatSleepMinutes(m.value, locale)}
|
||||
<SleepNightCaption m={m} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(
|
||||
m.sampleCount as number,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
{/*
|
||||
v1.4.43 QoL (L8) — `formatDateOrRelative`
|
||||
renders timestamps inside the last 24 h
|
||||
as "vor 3 min" so a fresh entry visible
|
||||
@@ -1038,279 +1050,286 @@ export function MeasurementList({
|
||||
list view never disagrees about how to
|
||||
phrase "when".
|
||||
*/}
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.notes ? (
|
||||
<span title={m.notes}>
|
||||
{truncateComment(m.notes)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isGrouped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() =>
|
||||
toggleExpand(m.dayKey as string)
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
: t("measurements.expandDay")
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.notes ? (
|
||||
<span title={m.notes}>
|
||||
{truncateComment(m.notes)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isGrouped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() =>
|
||||
toggleExpand(m.dayKey as string)
|
||||
}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isGrouped && isExpanded && (
|
||||
<TableRow id={drilldownId}>
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="desktop"
|
||||
/>
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
: t("measurements.expandDay")
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
}
|
||||
title={t(
|
||||
"measurements.deleteConfirmTitle",
|
||||
)}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{isGrouped && isExpanded && (
|
||||
<TableRow id={drilldownId}>
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="desktop"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile list */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{data.measurements.map((m) => {
|
||||
const Icon = TYPE_ICONS[m.type];
|
||||
const isGrouped =
|
||||
m.dayKey !== undefined && m.sampleCount !== undefined;
|
||||
const isSleep = m.type === "SLEEP_DURATION";
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — see desktop counterpart.
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<ListRow
|
||||
key={m.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 — multi-select checkbox in a 44px tap
|
||||
{/* Mobile list — rendered only when mobile (v1.28.42 H3). */}
|
||||
{isMobile && (
|
||||
<div className="space-y-2 md:hidden">
|
||||
{data.measurements.map((m) => {
|
||||
const Icon = TYPE_ICONS[m.type];
|
||||
const isGrouped =
|
||||
m.dayKey !== undefined && m.sampleCount !== undefined;
|
||||
const isSleep = m.type === "SLEEP_DURATION";
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — see desktop counterpart.
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<ListRow
|
||||
key={m.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 — multi-select checkbox in a 44px tap
|
||||
target; absent for synthetic grouped rows. */}
|
||||
{!isGrouped && (
|
||||
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
|
||||
// (itself a `<button role=checkbox>`) inside a
|
||||
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
|
||||
// button may not nest inside a button, and the
|
||||
// invalid markup made React 19 fail hydration on
|
||||
// every list paint. The wrapper is now a plain
|
||||
// layout `<div>`; the Checkbox stays the single
|
||||
// control and owns the 44px hit area via an
|
||||
// `after` hit-slop (clicks on a pseudo-element
|
||||
// hit-test against its host button).
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
className="relative after:absolute after:-inset-3.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t("measurements.openMetricInsights", {
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
})}
|
||||
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/* v1.22 — metadata badges sit on the scale
|
||||
{!isGrouped && (
|
||||
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
|
||||
// (itself a `<button role=checkbox>`) inside a
|
||||
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
|
||||
// button may not nest inside a button, and the
|
||||
// invalid markup made React 19 fail hydration on
|
||||
// every list paint. The wrapper is now a plain
|
||||
// layout `<div>`; the Checkbox stays the single
|
||||
// control and owns the 44px hit area via an
|
||||
// `after` hit-slop (clicks on a pseudo-element
|
||||
// hit-test against its host button).
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
className="relative after:absolute after:-inset-3.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t("measurements.openMetricInsights", {
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
})}
|
||||
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/* v1.22 — metadata badges sit on the scale
|
||||
token `text-xs` (12 px, the mobile legibility
|
||||
baseline) instead of an arbitrary per-site
|
||||
size, so the row tracks the type scale. */}
|
||||
{(m.type === "BLOOD_PRESSURE_SYS" ||
|
||||
m.type === "BLOOD_PRESSURE_DIA") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 h-5 px-1 text-xs"
|
||||
>
|
||||
{t(TYPE_LABEL_KEYS[m.type])}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{/* v1.4.39.3 — mirror the desktop table:
|
||||
{(m.type === "BLOOD_PRESSURE_SYS" ||
|
||||
m.type === "BLOOD_PRESSURE_DIA") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 h-5 px-1 text-xs"
|
||||
>
|
||||
{t(TYPE_LABEL_KEYS[m.type])}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{/* v1.4.39.3 — mirror the desktop table:
|
||||
grouped daily aggregates stay integer,
|
||||
non-grouped single readings render with
|
||||
their native decimal precision so
|
||||
"78.4 kg" no longer truncates to "78".
|
||||
v1.11.5 — sleep rows render TIME ASLEEP. */}
|
||||
{isSleep ? (
|
||||
formatSleepMinutes(m.value, locale)
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{isSleep ? (
|
||||
formatSleepMinutes(m.value, locale)
|
||||
<SleepNightCaption m={m} />
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
</>
|
||||
isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(m.sampleCount as number),
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
{isSleep ? (
|
||||
<SleepNightCaption m={m} />
|
||||
) : (
|
||||
isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(m.sampleCount as number),
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{/*
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{/*
|
||||
v1.4.43 QoL (L8) — see desktop
|
||||
counterpart at the same `measuredAt`
|
||||
site. Relative under 24 h, absolute
|
||||
older.
|
||||
*/}
|
||||
<span>{formatDateOrRelative(m.measuredAt, t)}</span>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`ml-1.5 h-4 px-1 text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
{m.notes && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{truncateComment(m.notes)}
|
||||
<span>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</span>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`ml-1.5 h-4 px-1 text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{m.notes && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{truncateComment(m.notes)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isGrouped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() => toggleExpand(m.dayKey as string)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
: t("measurements.expandDay")
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isGrouped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() => toggleExpand(m.dayKey as string)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
: t("measurements.expandDay")
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<DeleteButton
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isGrouped && isExpanded && (
|
||||
<div id={drilldownId}>
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isGrouped && isExpanded && (
|
||||
<div id={drilldownId}>
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -269,6 +269,16 @@ describe("dependent-key bundles", () => {
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
||||
});
|
||||
|
||||
// v1.28.42 (M2) — the dashboard snapshot embeds a mood block + feeds the
|
||||
// score ring, but a mood write only invalidated the mood bundle, leaving the
|
||||
// Startseite tile / score stale for up to ~120 s. Mirror the measurement
|
||||
// (v1.18.9) and medication (v1.16.11) bundles. Pin the membership so a future
|
||||
// refactor can't silently drop the dependency again.
|
||||
it("moodDependentKeys bundles the dashboard snapshot (v1.28.42)", () => {
|
||||
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
||||
});
|
||||
|
||||
it("medicationDependentKeys bundle covers medications + analytics + achievements", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["medications"]));
|
||||
|
||||
+28
-17
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { getDateTimeFormat, getNumberFormat } from "./intl/formatter-cache";
|
||||
import { isValidTimezone } from "./tz/format";
|
||||
|
||||
/**
|
||||
@@ -160,42 +161,52 @@ export function makeFormatters(
|
||||
// so axis month names follow the UI language, not the date-order pin.
|
||||
const dateLocale = dateOrderLocale(dateFormat, intlLocale);
|
||||
|
||||
// v1.28.42 (H2) — the returned closures used to construct a fresh
|
||||
// `Intl.NumberFormat` / `Intl.DateTimeFormat` on every call. `makeFormatters`
|
||||
// is memoised per (locale, tz, timeFormat, dateFormat) on the client, but a
|
||||
// dense history list still calls these closures thousands of times per
|
||||
// render, rebuilding an identical formatter each time (V8's toLocale cache
|
||||
// busts on any options object) → main-thread stalls. Route through the
|
||||
// process-wide formatter cache so each distinct (locale, options) shape is
|
||||
// constructed once. `getDateTimeFormat(...).format(date)` is behaviourally
|
||||
// identical to `Date.prototype.toLocale*String(locale, options)` — the latter
|
||||
// builds the same `Intl.DateTimeFormat` internally — so rendered output is
|
||||
// unchanged. The options carry `timeZone` + the hour-cycle/date-field shape,
|
||||
// so the cache key already distinguishes every variant.
|
||||
return {
|
||||
number: (value, fractionDigits) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
getNumberFormat(intlLocale, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
integer: (value) =>
|
||||
new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 0 }).format(
|
||||
value,
|
||||
),
|
||||
getNumberFormat(intlLocale, { maximumFractionDigits: 0 }).format(value),
|
||||
|
||||
percent: (value, fractionDigits = 0) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
getNumberFormat(intlLocale, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
date: (value) =>
|
||||
asDate(value).toLocaleDateString(dateLocale, {
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
|
||||
dateShort: (value) =>
|
||||
asDate(value).toLocaleDateString(dateLocale, {
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
|
||||
dateTime: (value) =>
|
||||
asDate(value).toLocaleString(dateLocale, {
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
@@ -203,29 +214,29 @@ export function makeFormatters(
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourOpts,
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
|
||||
time: (value) =>
|
||||
asDate(value).toLocaleTimeString(intlLocale, {
|
||||
getDateTimeFormat(intlLocale, {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourOpts,
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
|
||||
dateWithWeekday: (value) =>
|
||||
asDate(value).toLocaleDateString(dateLocale, {
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
|
||||
monthShort: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
getDateTimeFormat(intlLocale, {
|
||||
timeZone: tz,
|
||||
month: "short",
|
||||
}),
|
||||
}).format(asDate(value)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,4 +37,36 @@ describe("formatter-cache", () => {
|
||||
expect(a).toBe(b);
|
||||
expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08");
|
||||
});
|
||||
|
||||
// v1.28.42 (H2) — `makeFormatters` routes every date/time closure through
|
||||
// this cache, so the memo key MUST distinguish the two variables that make
|
||||
// otherwise-identical shapes render differently: the profile timezone and
|
||||
// the hour-cycle option. A collision on either would render the wrong wall
|
||||
// clock / wrong local day.
|
||||
it("keys DateTimeFormat by timeZone (no cross-tz collision)", () => {
|
||||
const opts = { hour: "2-digit", minute: "2-digit" } as const;
|
||||
const berlin = getDateTimeFormat("en-US", {
|
||||
...opts,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
const tokyo = getDateTimeFormat("en-US", {
|
||||
...opts,
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(berlin).not.toBe(tokyo);
|
||||
// 14:30 UTC → 16:30 Berlin (CEST) vs 23:30 Tokyo.
|
||||
const sample = new Date("2026-04-18T14:30:00Z");
|
||||
expect(berlin.format(sample)).not.toBe(tokyo.format(sample));
|
||||
});
|
||||
|
||||
it("keys DateTimeFormat by hour-cycle option", () => {
|
||||
const base = {
|
||||
timeZone: "Europe/Berlin",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
} as const;
|
||||
const auto = getDateTimeFormat("en-US", base);
|
||||
const h24 = getDateTimeFormat("en-US", { ...base, hourCycle: "h23" });
|
||||
expect(auto).not.toBe(h24);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,17 @@ export const measurementDependentKeys = [
|
||||
/**
|
||||
* Keys that should be invalidated when a mood entry is created, updated or
|
||||
* deleted.
|
||||
*
|
||||
* v1.28.42 (M2) — `dashboardSnapshot` joins the bundle, mirroring the
|
||||
* measurement (v1.18.9) and medication (v1.16.11) fixes for the same #38
|
||||
* stale-read class. The dashboard snapshot embeds a mood block and feeds the
|
||||
* score ring, but `moodDependentKeys` omitted the key, so a mood logged from
|
||||
* the Startseite quick-entry sheet (or on `/mood`) left the dashboard tile /
|
||||
* score showing the pre-write value for up to ~120 s (the snapshot query runs
|
||||
* refetchOnMount/WindowFocus off with a 120 s poll). The server snapshot bucket
|
||||
* is already hard-evicted on a mood write (`invalidateUserMood` sweeps
|
||||
* `${userId}|`), so the refetch this invalidation triggers returns post-write
|
||||
* data at once.
|
||||
*/
|
||||
export const moodDependentKeys = [
|
||||
queryKeys.moodEntries(),
|
||||
@@ -113,6 +124,7 @@ export const moodDependentKeys = [
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user