Merge pull request #499 from MBombeck/release/v1.28.42

release/v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 03:33:46 +02:00
commit ff654b295f
13 files changed
+708 -426

No files matched your search

+15
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+50 -12
View File
@@ -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);
});
});
+395 -376
View File
@@ -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>
)}
</>
)}
+10
View File
@@ -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
View File
@@ -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);
});
});
+12
View File
@@ -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(),
];
/**