From d4be5686258f518f469272c494dcac9cebb0de86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 03:18:00 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.42=20=E2=80=94=20snappi?= =?UTF-8?q?er=20lists,=20correct=20mood=20refresh,=20safer=20first=20sign-?= =?UTF-8?q?up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 15 + docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../api/auth/register/__tests__/route.test.ts | 102 +++ src/app/api/auth/register/route.ts | 62 +- ...asurement-list-apple-health-badge.test.tsx | 29 +- .../measurement-list-bp-badge.test.tsx | 50 +- .../measurements/measurement-list.tsx | 771 +++++++++--------- src/lib/__tests__/query-keys.test.ts | 10 + src/lib/format-locale.ts | 45 +- .../intl/__tests__/formatter-cache.test.ts | 32 + src/lib/query-keys/index.ts | 12 + 13 files changed, 708 insertions(+), 426 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ab7c46f..79df444fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08022cd1a..4368c1f8d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 9b6ba4312..01c82cfbc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 0ed3acd53..d7253c1bb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index c7e9372e1..870a5e1b2 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -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 { 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; + }) => ({ + 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", diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a8acb77cb..8189b0789 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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 diff --git a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx index 0368f7b19..1ab32caa9 100644 --- a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx @@ -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 `` 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); }); }); diff --git a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx index d302283a9..c24a1c746 100644 --- a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx @@ -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); }); }); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 2d5488a1f..03cfed717 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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 */} -
-
- - - - {/* v1.15.13 — select-all-on-page header checkbox. */} - +
+ + + + {/* v1.15.13 — select-all-on-page header checkbox. */} + + + + {t("measurements.type")} + + - - - {t("measurements.type")} - - - - - {t("measurements.comment")} - - - - {t("measurements.actions")} - - - - - {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 ( - - - - {/* Grouped/synthetic rows aren't individually + + + {t("measurements.comment")} + + + + {t("measurements.actions")} + + + + + {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 ( + + + + {/* Grouped/synthetic rows aren't individually deletable, so they have no checkbox. */} - {!isGrouped && ( - onToggleRow(m.id)} - aria-label={t("dataList.selectRow")} - /> - )} - - - - - - - - {/* v1.4.39.3 — non-grouped rows render the + + + + + {/* 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)} - - - ) : ( - <> - {isGrouped - ? fmt.integer(m.value) - : fmt.number( - m.value, - rawDisplayFractionDigits(m.type), - )}{" "} - {m.unit} - {isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer( - m.sampleCount as number, - ), - })} - - )} - - )} - - {/* + {isSleep ? ( + <> + {formatSleepMinutes(m.value, locale)} + + + ) : ( + <> + {isGrouped + ? fmt.integer(m.value) + : fmt.number( + m.value, + rawDisplayFractionDigits(m.type), + )}{" "} + {m.unit} + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer( + m.sampleCount as number, + ), + })} + + )} + + )} + + {/* 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". */} - - {formatDateOrRelative(m.measuredAt, t)} - - - {m.notes ? ( - - {truncateComment(m.notes)} - - ) : ( - - - )} - - - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} - - -
- {isGrouped ? ( - + + {formatDateOrRelative(m.measuredAt, t)} + + + {m.notes ? ( + + {truncateComment(m.notes)} + ) : ( - <> + - + )} + + + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} + + +
+ {isGrouped ? ( - - deleteMutation.mutate(m.id) + data-testid="measurement-day-expand" + onClick={() => + toggleExpand(m.dayKey as string) } - title={t("measurements.deleteConfirmTitle")} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} -
-
- - {isGrouped && isExpanded && ( - - - + aria-expanded={isExpanded} + aria-controls={drilldownId} + aria-label={ + isExpanded + ? t("measurements.collapseDay") + : t("measurements.expandDay") + } + > + + + ) : ( + <> + + + deleteMutation.mutate(m.id) + } + title={t( + "measurements.deleteConfirmTitle", + )} + description={t( + "measurements.deleteConfirmDescription", + )} + /> + + )} +
- )} -
- ); - })} -
-
- + {isGrouped && isExpanded && ( + + + + + + )} + + ); + })} + + + + )} - {/* Mobile list */} -
- {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 ( - -
-
- {/* v1.15.13 — multi-select checkbox in a 44px tap + {/* Mobile list — rendered only when mobile (v1.28.42 H3). */} + {isMobile && ( +
+ {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 ( + +
+
+ {/* 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 ` - )} -
- {/* v1.22 — metadata badges sit on the scale + {!isGrouped && ( + // v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox + // (itself a ` + )} +
+ {/* 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") && ( - - {t(TYPE_LABEL_KEYS[m.type])} - - )} - - {/* v1.4.39.3 — mirror the desktop table: + {(m.type === "BLOOD_PRESSURE_SYS" || + m.type === "BLOOD_PRESSURE_DIA") && ( + + {t(TYPE_LABEL_KEYS[m.type])} + + )} + + {/* 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} + + )} + {isSleep ? ( - formatSleepMinutes(m.value, locale) + ) : ( - <> - {isGrouped - ? fmt.integer(m.value) - : fmt.number( - m.value, - rawDisplayFractionDigits(m.type), - )}{" "} - {m.unit} - + isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + ) )} - - {isSleep ? ( - - ) : ( - isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer(m.sampleCount as number), - })} - - ) - )} -

- {/* +

+ {/* v1.4.43 QoL (L8) — see desktop counterpart at the same `measuredAt` site. Relative under 24 h, absolute older. */} - {formatDateOrRelative(m.measuredAt, t)} - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} -

- {m.notes && ( -

- {truncateComment(m.notes)} + + {formatDateOrRelative(m.measuredAt, t)} + + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )}

- )} + {m.notes && ( +

+ {truncateComment(m.notes)} +

+ )} +
-
-
- {isGrouped ? ( - - ) : ( - <> +
+ {isGrouped ? ( - deleteMutation.mutate(m.id)} - title={t("measurements.deleteConfirmTitle")} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} + ) : ( + <> + + deleteMutation.mutate(m.id)} + title={t("measurements.deleteConfirmTitle")} + description={t( + "measurements.deleteConfirmDescription", + )} + /> + + )} +
-
- {isGrouped && isExpanded && ( -
- -
- )} -
- ); - })} -
+ {isGrouped && isExpanded && ( +
+ +
+ )} + + ); + })} +
+ )} )} diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 79f01b9f1..1d6585801 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -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"])); diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 54626d6ce..8628b9ba6 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -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)), }; } diff --git a/src/lib/intl/__tests__/formatter-cache.test.ts b/src/lib/intl/__tests__/formatter-cache.test.ts index e55e3b643..5a4057072 100644 --- a/src/lib/intl/__tests__/formatter-cache.test.ts +++ b/src/lib/intl/__tests__/formatter-cache.test.ts @@ -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); + }); }); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 1aa7b4199..5e08c4ce6 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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(), ]; /**