Merge the interface review fixes into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 23:04:04 +02:00
commit 4641943481
59 files changed
+1714 -453

No files matched your search

@@ -60,6 +60,15 @@ describe("the shell owns the safe area", () => {
expect(css).toMatch(/@custom-variant pointer-fine/);
});
it("bottom toasts clear the bar under the same query the bar follows", () => {
// Sonner's own mobile offset stops at 600 px; the bar shows up to `md`
// and on a phone held sideways, so the lift keys on `shell-mobile`.
const css = code(read("src/app/globals.css"));
expect(css).toMatch(
/html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/,
);
});
it("the bottom bar and the Coach button step aside for the keyboard", () => {
expect(read("src/components/layout/bottom-nav.tsx")).toContain(
'data-keyboard-hide=""',
@@ -69,6 +78,51 @@ describe("the shell owns the safe area", () => {
);
});
it("a bottom sheet on the keyboard drops its home-indicator padding", () => {
// Standing on the keyboard, the safe-area inset left a ~34 px gap
// between the sheet's last row and the keys.
expect(code(read("src/app/globals.css"))).toMatch(
/@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/,
);
const sheet = code(read("src/components/ui/responsive-sheet.tsx"));
const padded = (sheet.match(
/"[^"]*pb-\[calc\(env\(safe-area-inset-bottom[^"]*"/g,
) ?? []) as string[];
expect(padded.length).toBe(2);
for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/);
});
it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => {
// Unlayered, the rules outranked every utility on the same element: a
// `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored
// without a trace. In the components layer a utility wins, so the bottom
// sheet must not carry one that would pin it under the keyboard.
const css = code(read("src/app/globals.css"));
const start = css.indexOf("@layer components {");
expect(start).toBeGreaterThan(-1);
let depth = 0;
let end = start;
for (let i = css.indexOf("{", start); i < css.length; i += 1) {
if (css[i] === "{") depth += 1;
else if (css[i] === "}" && --depth === 0) {
end = i;
break;
}
}
const layer = css.slice(start, end);
expect(layer).toMatch(/\.shell-safe-area\s*\{/);
expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/);
expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/);
expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/);
// Sonner's own rules are unlayered; the toast lift must stay so.
expect(layer).not.toMatch(/data-sonner-toaster/);
const sheet = code(read("src/components/ui/sheet.tsx"));
const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1];
expect(bottomBranch).toBeDefined();
expect(bottomBranch).not.toMatch(/(?<![\w-])bottom-/);
});
it("the sheet primitive names its side for those rules", () => {
expect(read("src/components/ui/sheet.tsx")).toMatch(
/data-sheet-side=\{side\}/,
@@ -0,0 +1,43 @@
/**
* Links point at a route that renders, not at a retired one that only
* redirects: every hop through a redirect is a round trip on a phone, and
* the redirect stubs exist for old bookmarks, not for the app's own links.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
/** Retired route → where it redirects. */
const RETIRED: Record<string, string> = {
"/medications/new": "/medications?new=1",
};
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
if (name === "__tests__" || name === "generated") return [];
const abs = join(dir, name);
if (statSync(abs).isDirectory()) return walk(abs);
return /\.tsx?$/.test(name) ? [abs] : [];
});
}
describe("no link targets a retired route", () => {
const files = walk(join(ROOT, "src"));
it("scans the source tree", () => {
expect(files.length).toBeGreaterThan(100);
});
it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => {
const pattern = new RegExp(
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${route.replace(/\//g, "\\/")}["'\`?#]`,
);
const offenders = files
.filter((file) => pattern.test(readFileSync(file, "utf8")))
.map((file) => relative(ROOT, file));
expect(offenders).toEqual([]);
});
});
+88 -55
View File
@@ -76,6 +76,13 @@
}
}
/*
* `keyboard-open:` — while the on-screen keyboard is up (see the keyboard
* rules further down). A bottom sheet then stands on the keyboard, not on
* the home indicator, so its safe-area padding has nothing left to clear.
*/
@custom-variant keyboard-open (html[data-keyboard="open"] &);
@custom-variant shell-mobile {
@media (max-width: 47.98rem), (pointer: coarse) and (max-height: 520px) {
@slot;
@@ -587,66 +594,92 @@
}
/*
* `.shell-safe-area` — the authenticated app shell's share of the installed
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
* and side insets as padding, and the strip behind the status bar is painted
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
* the page colour, so the status bar reads as part of the bar beneath it. A
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
* The shell's safe area, the on-screen keyboard and the sheet clearances sit
* in the components layer, under the utilities, so a utility on the same
* element still wins and these rules cannot silently override one. The
* bottom sheet takes its `bottom` from here alone for that reason (the
* sheet primitive sets no `bottom-*`); the keyboard rules that must beat a
* utility say `!important`, which in a layer outranks any utility.
*/
.shell-safe-area {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
background: linear-gradient(var(--card), var(--card)) top / 100%
env(safe-area-inset-top, 0px) no-repeat;
@layer components {
/*
* `.shell-safe-area` — the authenticated app shell's share of the installed
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
* and side insets as padding, and the strip behind the status bar is painted
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
* the page colour, so the status bar reads as part of the bar beneath it. A
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
*/
.shell-safe-area {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
background: linear-gradient(var(--card), var(--card)) top / 100%
env(safe-area-inset-top, 0px) no-repeat;
}
/*
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
* shrink only the visual viewport when the keyboard opens: everything
* `position: fixed` stays anchored to the layout viewport, so a bottom sheet
* keeps its footer (Save, the Coach composer) behind the keyboard and the
* bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once
* in the providers) publishes the covered height as `--keyboard-inset` and
* sets `data-keyboard` on `<html>` while it is open.
*
* Bottom sheets then stand on the keyboard and give up the height it takes,
* so their own scroll body (every bottom sheet is a flex column with a
* scrolling middle) carries the form, and the bottom bar and the Coach
* button step aside until the keyboard is gone.
*/
[data-sheet-side="bottom"] {
bottom: var(--keyboard-inset, 0px);
}
html[data-keyboard="open"] [data-sheet-side="bottom"] {
max-height: calc(
100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) -
0.5rem
) !important;
}
html[data-keyboard="open"] [data-keyboard-hide] {
display: none !important;
}
/*
* Side and top sheets run the full height of the window. In an installed
* app on a tablet the status bar sits over their first 24 px, and a phone
* held sideways has its notch over one side, so the panel keeps its header
* clear of both. A transparent border rather than padding: every consumer
* sets its own padding (often `p-0`), which would override a padding here,
* while the border adds the clearance under any of them and lets the
* panel's own background show through it.
*/
[data-sheet-side="right"],
[data-sheet-side="left"],
[data-sheet-side="top"] {
border-top: env(safe-area-inset-top, 0px) solid transparent;
}
[data-sheet-side="right"] {
border-right: env(safe-area-inset-right, 0px) solid transparent;
}
[data-sheet-side="left"] {
border-left: env(safe-area-inset-left, 0px) solid transparent;
}
}
/*
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
* shrink only the visual viewport when the keyboard opens: everything
* `position: fixed` stays anchored to the layout viewport, so a bottom sheet
* keeps its footer (Save, the Coach composer) behind the keyboard and the
* bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once
* in the providers) publishes the covered height as `--keyboard-inset` and
* sets `data-keyboard` on `<html>` while it is open.
*
* Bottom sheets then stand on the keyboard and give up the height it takes,
* so their own scroll body (every bottom sheet is a flex column with a
* scrolling middle) carries the form, and the bottom bar and the Coach
* button step aside until the keyboard is gone.
* Toasts clear the bottom bar wherever the phone shell shows it. The
* toaster's `mobileOffset` (providers.tsx) applies below 600 px only, while
* the bar follows the shell (up to `md`, and a phone held sideways): between
* the two the "new version" toast sat over the tabs. Unlayered and one `html`
* deeper than Sonner's injected `[data-sonner-toaster][data-y-position]`
* rule, which is unlayered too and lands after this sheet. The Coach panel's
* rule further down still outranks it while that panel is open.
*/
[data-sheet-side="bottom"] {
bottom: var(--keyboard-inset, 0px);
}
html[data-keyboard="open"] [data-sheet-side="bottom"] {
max-height: calc(
100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem
) !important;
}
html[data-keyboard="open"] [data-keyboard-hide] {
display: none !important;
}
/*
* Side and top sheets run the full height of the window. In an installed
* app on a tablet the status bar sits over their first 24 px, and a phone
* held sideways has its notch over one side, so the panel keeps its header
* clear of both. A transparent border rather than padding: every consumer
* sets its own padding (often `p-0`), which would override a padding here,
* while the border adds the clearance under any of them and lets the
* panel's own background show through it.
*/
[data-sheet-side="right"],
[data-sheet-side="left"],
[data-sheet-side="top"] {
border-top: env(safe-area-inset-top, 0px) solid transparent;
}
[data-sheet-side="right"] {
border-right: env(safe-area-inset-right, 0px) solid transparent;
}
[data-sheet-side="left"] {
border-left: env(safe-area-inset-left, 0px) solid transparent;
html [data-sonner-toaster][data-y-position="bottom"] {
@variant shell-mobile {
bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 0.75rem);
}
}
/*
+2 -6
View File
@@ -18,6 +18,7 @@ import {
type MoodAggregates,
} from "@/lib/insights/mood-aggregates";
import type { MoodDailySeries } from "@/lib/analytics/mood-series";
import { settleWithin } from "@/lib/async/settle-within";
import { resolveModuleMap } from "@/lib/modules/gate";
import { queryKeys } from "@/lib/query-keys";
@@ -32,12 +33,7 @@ import InsightsMoodPageClient from "./page-client";
const PREFETCH_BUDGET_MS = 1_500;
function withinBudget<T>(work: Promise<T>): Promise<T | null> {
return Promise.race([
work,
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), PREFETCH_BUDGET_MS),
),
]).catch(() => null);
return settleWithin(work, PREFETCH_BUDGET_MS, null).catch(() => null);
}
/**
+4 -5
View File
@@ -9,7 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { THEME_COLOR } from "@/lib/pwa/theme-color";
import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
@@ -80,9 +80,6 @@ export const viewport: Viewport = {
],
};
// Inline script to apply theme before first paint (prevents FOUC)
const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`;
export default async function RootLayout({
children,
}: Readonly<{
@@ -101,7 +98,9 @@ export default async function RootLayout({
<script
suppressHydrationWarning
nonce={nonce}
dangerouslySetInnerHTML={{ __html: themeScript }}
// Applies the theme (and its status-bar colour) before
// first paint, so neither flashes the wrong theme.
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
/>
{/*
Locale-catalog boot script (generated into public/i18n/ by
+2 -6
View File
@@ -13,6 +13,7 @@ import {
type BatchWindow,
} from "@/lib/dashboard/batch-chart-types";
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
import { settleWithin } from "@/lib/async/settle-within";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
import { queryKeys } from "@/lib/query-keys";
@@ -30,11 +31,6 @@ import DashboardPageClient from "./page-client";
*/
const DIGEST_SOFT_BUDGET_MS = 400;
/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */
function softTimeout<T>(ms: number): Promise<T | undefined> {
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
}
/**
* Thin RSC wrapper around the (client) dashboard page.
*
@@ -144,7 +140,7 @@ export default async function DashboardPage() {
: Promise.resolve(undefined);
const [digest, series] = await Promise.all([
Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]),
settleWithin(digestWork, DIGEST_SOFT_BUDGET_MS, undefined),
seriesWork,
]);
@@ -15,7 +15,7 @@
import { describe, expect, it } from "vitest";
import { resolveVersionPollDecision } from "../version-poller";
import { cachesToEvict, resolveVersionPollDecision } from "../version-poller";
describe("resolveVersionPollDecision", () => {
it("is up-to-date when live matches the shell", () => {
@@ -144,3 +144,31 @@ describe("evictAndReload", () => {
]);
});
});
describe("cachesToEvict", () => {
const keys = [
"healthlog-static-v1.41.2",
"healthlog-pages-v1.41.2",
"healthlog-data-v1.41.2",
"healthlog-static-v1.42.0",
"another-app-cache",
];
it("keeps the target release's caches the new worker just filled", () => {
expect(cachesToEvict(keys, "1.42.0")).toEqual([
"healthlog-static-v1.41.2",
"healthlog-pages-v1.41.2",
"healthlog-data-v1.41.2",
]);
});
it("reads the target with or without its v", () => {
expect(cachesToEvict(keys, "v1.42.0")).not.toContain(
"healthlog-static-v1.42.0",
);
});
it("never touches another app's caches", () => {
expect(cachesToEvict(keys, "1.43.0")).not.toContain("another-app-cache");
});
});
@@ -131,6 +131,12 @@ describe("encryption coverage — the per-column table", () => {
expect(html).not.toContain('data-slot="encryption-column-rows"');
});
it("keeps the toggle's aria-controls target in the tree while collapsed", () => {
const html = renderCoverage(false);
expect(html).toContain('aria-controls="encryption-columns-panel"');
expect(html).toMatch(/<div id="encryption-columns-panel" hidden=""/);
});
it("lists only columns with rows when opened, with a toggle for all", () => {
const html = renderCoverage(true);
expect(html).toContain("User.withRows");
+85 -79
View File
@@ -410,87 +410,93 @@ export function ColumnCoverage({
count: withRows.length,
})}
</button>
{open ? (
<div id="encryption-columns-panel" className="space-y-3">
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
{/* The panel stays in the tree, empty and hidden, while closed, so
the toggle's aria-controls always names an element. */}
<div id="encryption-columns-panel" hidden={!open} className="space-y-3">
{open ? (
<>
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</div>
) : null}
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">
{active.toLocaleString()}
</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* A chart whose chunk fails never reports its visible window, and the stat
* strip above it holds its cells for that report. The boundary's catch
* settles it with "no window" so the strip paints the full-range summary
* instead of staying a skeleton for good.
*/
import type { ComponentProps, ReactElement } from "react";
import { describe, expect, it, vi } from "vitest";
import { ChartBoundary } from "@/components/charts/chart-error-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
type Props = ComponentProps<typeof HealthChartDynamic>;
describe("chart boundary settles the stat strip", () => {
it("reports an empty window when the chart fails to mount", () => {
const onVisibleStats = vi.fn();
const element = HealthChartDynamic({
types: ["WEIGHT"],
onVisibleStats,
} as unknown as Props) as ReactElement<{
onError?: (error: Error) => void;
}>;
expect(element.props.onError).toBeTypeOf("function");
element.props.onError?.(new Error("chunk"));
expect(onVisibleStats).toHaveBeenCalledWith(null);
});
it("passes no handler for a chart nobody reads stats from", () => {
const element = HealthChartDynamic({
types: ["WEIGHT"],
} as unknown as Props) as ReactElement<{ onError?: unknown }>;
expect(element.props.onError).toBeUndefined();
});
it("the boundary calls its handler when it catches", () => {
const onError = vi.fn();
const boundary = new ChartBoundary({
fallback: null,
children: null,
onError,
});
const error = new Error("chunk");
boundary.componentDidCatch(error);
expect(onError).toHaveBeenCalledWith(error);
});
});
+15 -3
View File
@@ -95,13 +95,16 @@ export function ChartErrorState({
interface BoundaryProps {
fallback: ReactNode;
children: ReactNode;
/** Called once the boundary has caught, after the fallback commits. */
onError?: (error: Error) => void;
}
interface BoundaryState {
failed: boolean;
}
class Boundary extends Component<BoundaryProps, BoundaryState> {
/** Exported for its unit test; mount `<ChartErrorBoundary>` instead. */
export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false };
static getDerivedStateFromError(): BoundaryState {
@@ -118,6 +121,7 @@ class Boundary extends Component<BoundaryProps, BoundaryState> {
};
g.__healthlog_onError?.(error);
}
this.props.onError?.(error);
}
render(): ReactNode {
@@ -134,12 +138,20 @@ class Boundary extends Component<BoundaryProps, BoundaryState> {
*/
export function ChartErrorBoundary({
children,
onError,
}: {
children: ReactNode;
/**
* Lets the page settle anything that waited on the chart: a stat strip
* that holds its cells for the chart's window would otherwise wait for a
* report the failed chart can never send.
*/
onError?: (error: Error) => void;
}): ReactElement {
const { t } = useTranslations();
return (
<Boundary
<ChartBoundary
onError={onError}
fallback={
// Wrapped in the chart-card shell so a chunk failure renders
// as one solid card among the other cards, not a bare dashed
@@ -159,6 +171,6 @@ export function ChartErrorBoundary({
}
>
{children}
</Boundary>
</ChartBoundary>
);
}
@@ -66,8 +66,15 @@ export function HealthChartDynamic(
: props.showDataTable
? HealthChartLazyTable
: HealthChartLazy;
// A chart that never mounts never reports its window, and a stat strip
// holding its cells for that report would stay a skeleton for good. The
// failure settles it with "no window", so the strip falls back to the
// full-range summary it already has.
const { onVisibleStats } = props;
return (
<ChartErrorBoundary>
<ChartErrorBoundary
onError={onVisibleStats ? () => onVisibleStats(null) : undefined}
>
<Lazy {...props} />
</ChartErrorBoundary>
);
@@ -63,6 +63,7 @@ import { formatDateShort } from "@/lib/format";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartDataTable } from "./chart-data-table";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartErrorState } from "./chart-error-state";
import { ChartOverlayControls } from "./chart-overlay-controls";
@@ -563,7 +564,7 @@ export function MedicationComplianceChart({
action={
canWriteDomain("medications") ? (
<Button asChild size="sm">
<Link href="/medications/new">
<Link href="/medications?new=1">
{t("charts.medicationsEmptyAction")}
</Link>
</Button>
@@ -743,6 +744,23 @@ export function MedicationComplianceChart({
insetLeft={8 + 48}
insetRight={8}
/>
{/* The keyboard and screen-reader way to a day: the plot opens
days on a click or a tap only. */}
{chartDays.active ? (
<ChartDataTable
points={chartData.map((point) => ({
date: point.date,
timestamp: point.timestamp,
rate: point.rate,
}))}
columns={[{ key: "rate", label: t("dashboard.compliance7d") }]}
formatValue={(value) => `${fmt.integer(value)} %`}
formatDate={tzFmt.date}
bucket="day"
metricLabel={displayTitle}
dayLinks
/>
) : null}
</>
)}
</div>
+39 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { chartPointDayKey, rugFraction, rugPosition } from "../chart-day";
import {
chartPointDayKey,
rugFraction,
rugPosition,
timeRugFraction,
} from "../chart-day";
import { daysFromCoachSteps } from "../coach-day-chips";
import { preparationRows } from "../since-last-visit";
import type { CoachStep } from "@/lib/ai/coach/types";
@@ -152,3 +157,36 @@ describe("rug placement per axis", () => {
);
});
});
describe("rug on a time axis drawn from real instants", () => {
const at = (iso: string) => ({ timestamp: Date.parse(iso) });
it("keeps the first day when its reading came after noon UTC", () => {
const points = [at("2026-03-01T18:30:00Z"), at("2026-03-05T08:00:00Z")];
const days = ["2026-03-01", "2026-03-05"];
expect(rugFraction("2026-03-01", points, "time")).toBeNull();
expect(timeRugFraction("2026-03-01", points, days)).toBe(0);
});
it("keeps the last day when its reading came before noon UTC", () => {
const points = [at("2026-03-01T08:00:00Z"), at("2026-03-05T07:00:00Z")];
const days = ["2026-03-01", "2026-03-05"];
expect(rugFraction("2026-03-05", points, "time")).toBeNull();
expect(timeRugFraction("2026-03-05", points, days)).toBe(1);
});
it("gives a single reading its dot", () => {
const points = [at("2026-03-03T21:00:00Z")];
expect(rugFraction("2026-03-03", points, "time")).toBeNull();
expect(timeRugFraction("2026-03-03", points, ["2026-03-03"])).toBe(0.5);
});
it("spans the readings' own days, not their UTC dates", () => {
// An evening reading in a zone ahead of UTC is the next local day.
const points = [at("2026-03-01T23:30:00Z"), at("2026-03-04T23:30:00Z")];
const days = ["2026-03-02", "2026-03-05"];
expect(timeRugFraction("2026-03-01", points, days)).toBeNull();
expect(timeRugFraction("2026-03-05", points, days)).toBe(1);
expect(timeRugFraction("2026-03-06", points, days)).toBeNull();
});
});
@@ -21,7 +21,10 @@ import { describe, expect, it } from "vitest";
* B. a file that draws its own Recharts chart over a date axis (an
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
* `OPEN_DAY_LINE`, the tooltip's props and its touch action);
* `OPEN_DAY_LINE`, the tooltip's props and its touch action), and
* offers the keyboard way to the same days: a `<ChartDataTable
* dayLinks>` or a `DayLink` of its own, or an entry on
* `KEYBOARD_ELSEWHERE` naming where that way lives;
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
* `useHeatmapDay`.
*
@@ -83,6 +86,29 @@ const NOT_HERE: Record<string, string> = {
"The server's own resource use, not a health record.",
};
/**
* Day-linked charts whose keyboard and screen-reader way to a day is not in
* their own file. Path → the file that carries it, and why there.
*/
const KEYBOARD_ELSEWHERE: Record<string, { file: string; why: string }> = {
"src/components/labs/lab-biomarker-chart.tsx": {
file: "src/components/labs/lab-history-list.tsx",
why: "The marker's readings list (its values page) dates every reading with a day link; the chart is the shape, the list the numbers.",
},
"src/components/mental-health/assessment-history-chart.tsx": {
file: "src/components/mental-health/assessment-history.tsx",
why: "The questionnaire history renders the chart and, under it, the list of every result with its day link.",
},
};
/** True when the file itself offers a keyboard way to its days. */
function hasKeyboardDoor(text: string): boolean {
return (
jsxTags(text, "ChartDataTable").some(mountOpensDays) ||
/<DayLink(?:At|Stated)?[\s>]/.test(text)
);
}
function walk(path: string): string[] {
const abs = join(ROOT, path);
if (statSync(abs).isFile()) return [abs];
@@ -201,6 +227,12 @@ function findMissingDoors(): {
if (wired) {
doors.charts += 1;
doors.chartFiles.push(file);
if (!hasKeyboardDoor(text) && !(file in KEYBOARD_ELSEWHERE)) {
missing.push({
file,
why: "a day-linked chart with no keyboard way to its days (a <ChartDataTable dayLinks> or a DayLink)",
});
}
} else if (!listed) {
missing.push({
file,
@@ -246,6 +278,19 @@ describe("every chart drawn in days opens its days, or says why not", () => {
expect(doors.heatmaps).toBeGreaterThanOrEqual(2);
});
it("every KEYBOARD_ELSEWHERE entry points at a file with day links", () => {
for (const [chart, { file, why }] of Object.entries(KEYBOARD_ELSEWHERE)) {
expect(source.get(chart), `${chart} does not exist`).toBeDefined();
expect(hasKeyboardDoor(source.get(chart)!), `${chart} has its own`).toBe(
false,
);
const text = source.get(file);
expect(text, `${file} does not exist`).toBeDefined();
expect(hasKeyboardDoor(text!), `${file} has no day link`).toBe(true);
expect(why.length).toBeGreaterThan(30);
}
});
it("every NOT_HERE entry names an existing chart and gives a reason", () => {
for (const [file, reason] of Object.entries(NOT_HERE)) {
const text = source.get(file);
@@ -0,0 +1,23 @@
/**
* A cycle and a trip reach the day view under a sentinel title the client
* words in the reader's language. The record's kind decides that, so a
* medication or a document someone named "cycle" keeps its own name.
*/
import { describe, expect, it } from "vitest";
import { dayTitle } from "../day-sections";
const t = (key: string) => `t:${key}`;
describe("dayTitle", () => {
it("words the cycle and travel sentinels", () => {
expect(dayTitle("cyclePhase", "cycle", t)).toBe("t:nav.cycle");
expect(dayTitle("cycleDayLog", "cycle", t)).toBe("t:nav.cycle");
expect(dayTitle("travel", "travel", t)).toBe("t:day.travel");
});
it("keeps a record's own title that happens to read like a sentinel", () => {
expect(dayTitle("medication", "cycle", t)).toBe("cycle");
expect(dayTitle("document", "travel", t)).toBe("travel");
});
});
@@ -0,0 +1,27 @@
/**
* The pinned heatmap tooltip on a 390 px phone. It shrinks to the room right
* of its left edge and wraps, so it never runs past the screen; measured in
* Chromium, a tap at the right border left it flush against the edge. The
* box keeps the 8 px gutter the clamp meant to leave.
*/
import { describe, expect, it } from "vitest";
import { tooltipBox } from "../heatmap-day";
describe("tooltipBox", () => {
it("keeps an 8 px gutter at the right edge for a tap near the border", () => {
const { left, maxWidth } = tooltipBox(380, 390);
expect(left).toBe(182);
expect(left + maxWidth).toBe(390 - 8);
});
it("sits beside the pointer away from the border", () => {
const { left, maxWidth } = tooltipBox(40, 390);
expect(left).toBe(50);
expect(left + maxWidth).toBe(382);
});
it("never starts left of the 8 px gutter", () => {
expect(tooltipBox(-20, 390).left).toBe(8);
});
});
+6 -1
View File
@@ -74,6 +74,8 @@ export interface ChartDayLinksOptions {
export interface ChartDayLinks {
active: boolean;
/** The calendar day of each drawn point, as the chart passed them in. */
days: ReadonlyArray<DateKey | null>;
coarse: boolean;
/** Index of the open day's point, for the dashed line. */
openIndex: number | undefined;
@@ -158,6 +160,7 @@ export function useChartDayLinks({
return {
active: enabled,
days,
coarse,
openIndex,
openDay: enabled ? openKey : null,
@@ -198,7 +201,8 @@ export function ChartDayFooter({
insetRight,
mark = "point",
}: {
links: Pick<ChartDayLinks, "active" | "coarse">;
links: Pick<ChartDayLinks, "active" | "coarse"> &
Partial<Pick<ChartDayLinks, "days">>;
/** The drawn points' noon-UTC day anchors (or instants), in data order. */
points: ReadonlyArray<{ timestamp: number }>;
axis?: ChartDayAxis;
@@ -211,6 +215,7 @@ export function ChartDayFooter({
<>
<DayRug
points={points}
pointDays={links.days}
axis={axis}
insetLeft={insetLeft}
insetRight={insetRight}
+51 -3
View File
@@ -144,6 +144,30 @@ export function rugFraction(
return at / (n - 1);
}
/**
* Where a day's dot sits under a time axis drawn from real instants (a lab
* result at 08:10, a questionnaire in the evening), given the calendar day
* each point stands for. The span is the first to the last of those days,
* both included: a noon anchor tested against the instants dropped the first
* day when its reading came after noon UTC, the last when its reading came
* before, and a single reading never had a dot. The dot then sits along the
* instants' own span, held to its ends.
*/
export function timeRugFraction(
day: DateKey,
points: readonly RugPoint[],
days: ReadonlyArray<DateKey | null>,
): number | null {
const keys = days.filter((d): d is DateKey => d !== null).sort();
if (keys.length === 0 || points.length === 0) return null;
if (day < keys[0]! || day > keys[keys.length - 1]!) return null;
const first = points[0]!.timestamp;
const last = points[points.length - 1]!.timestamp;
if (last === first) return 0.5;
const t = Date.parse(`${day}T12:00:00.000Z`);
return Math.min(1, Math.max(0, (t - first) / (last - first)));
}
/**
* The row of day dots under a daily chart. `insetLeft` / `insetRight` are the
* pixels between the chart box and the first and last point (margin, axis,
@@ -155,27 +179,51 @@ export function rugFraction(
*/
export function DayRug({
points,
pointDays,
insetLeft,
insetRight,
axis = "index",
}: {
points: readonly RugPoint[];
/**
* The calendar day of each point, in data order (the `days` the chart hands
* `useChartDayLinks`). A time axis drawn from real instants places its dots
* by these; without them every point is read as a noon-UTC day anchor.
*/
pointDays?: ReadonlyArray<DateKey | null>;
insetLeft: number;
insetRight: number;
axis?: ChartDayAxis;
}) {
const today = useTodayKey();
const open = useOpenDay();
const byDays = axis === "time" && pointDays !== undefined;
const dayKeys = byDays
? pointDays.filter((d): d is DateKey => d !== null).sort()
: [];
const first = points[0];
const last = points[points.length - 1];
const from = first ? chartPointDayKey(first.timestamp) : null;
const to = last ? chartPointDayKey(last.timestamp) : null;
const from = byDays
? (dayKeys[0] ?? null)
: first
? chartPointDayKey(first.timestamp)
: null;
const to = byDays
? (dayKeys[dayKeys.length - 1] ?? null)
: last
? chartPointDayKey(last.timestamp)
: null;
const index = useDayIndex(from, to);
const data = index.data;
const notable = new Set(data?.notable ?? []);
const days = Object.keys(data?.days ?? {})
.filter((day) => isOpenableDay(day, today))
.map((day) => ({ day, at: rugFraction(day, points, axis) }))
.map((day) => ({
day,
at: byDays
? timeRugFraction(day, points, pointDays)
: rugFraction(day, points, axis),
}))
.filter((entry): entry is { day: string; at: number } => entry.at !== null);
return (
<div
+13 -7
View File
@@ -165,11 +165,17 @@ const RUNNING_COLOR: Record<DayRunningKind, string> = {
* The server names a few records by a sentinel rather than by text it would
* have to translate: a cycle is titled "cycle", a trip "travel", a cycle
* day carries its flow level. They are worded here, in the reader's
* language; every other title is the record's own text.
* language; every other title is the record's own text. The record's kind
* decides, not the title: a medication or a document the person named
* "cycle" keeps its own name.
*/
function dayTitle(title: string, t: (key: string) => string): string {
if (title === "cycle") return t("nav.cycle");
if (title === "travel") return t("day.travel");
export function dayTitle(
kind: string,
title: string,
t: (key: string) => string,
): string {
if (kind === "cyclePhase" || kind === "cycleDayLog") return t("nav.cycle");
if (kind === "travel") return t("day.travel");
return title;
}
@@ -217,7 +223,7 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{dayTitle(item.title, t)}
{dayTitle(item.kind, item.title, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{parts.join(" · ")}
@@ -456,11 +462,11 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
data-slot="day-event-link"
className="focus-visible:ring-ring/50 block text-sm leading-5 font-medium after:absolute after:inset-0 after:rounded-md after:content-[''] focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-inherit"
>
{dayTitle(event.title, t)}
{dayTitle(event.kind, event.title, t)}
</Link>
) : (
<p className="text-sm leading-5 font-medium">
{dayTitle(event.title, t)}
{dayTitle(event.kind, event.title, t)}
</p>
)}
{event.meta ? (
+18 -6
View File
@@ -52,6 +52,21 @@ export interface HeatmapCellInput {
describe: () => string;
}
/**
* Where the heatmap tooltip sits beside the pointer: 10 px to its right,
* clamped so a tap near the right border keeps at least 200 px of room, and
* never wider than the room to the right edge less an 8 px gutter. The box
* shrinks to fit that room and wraps its text, so it cannot run past the
* edge; the width cap keeps the gutter the clamp meant to leave.
*/
export function tooltipBox(
x: number,
viewportWidth: number,
): { left: number; maxWidth: number } {
const left = Math.max(8, Math.min(x + 10, viewportWidth - 200 - 8));
return { left, maxWidth: viewportWidth - left - 8 };
}
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(null);
// The pointer type of the last press decides whether the click that
@@ -139,13 +154,10 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.max(8, Math.min(tooltip.x + 10, window.innerWidth - 200 - 8))
: tooltip.x + 10,
...(typeof window !== "undefined"
? tooltipBox(tooltip.x, window.innerWidth)
: { left: tooltip.x + 10 }),
top: tooltip.y - 30,
}}
>
@@ -110,4 +110,15 @@ describe("<DocumentBulkBar>", () => {
// assertion above is about the visit menu and not about an empty render.
expect(html).toContain("Link condition");
});
it("drops to the page edge only when the bottom bar is gone", () => {
// The bar follows the shell, not `md`: a phone held sideways is wider
// than `md` and still shows the bottom bar over a `md:` drop.
const html = render([]);
const cls =
html.match(/data-slot="document-bulk-bar"[^>]*class="([^"]*)"/)?.[1] ??
"";
expect(cls).toContain("shell-desktop:bottom-6");
expect(cls).not.toMatch(/(?<![\w-])md:bottom-/);
});
});
@@ -112,7 +112,10 @@ export function DocumentBulkBar({
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// Clear of the bottom bar (and the home indicator) for as long as
// the bar shows, which is the shell's call, not a width's: a phone
// held sideways is wider than `md` and still has the bar.
"bg-card border-border shell-desktop:bottom-6 fixed bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] left-1/2 z-40 -translate-x-1/2",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
@@ -76,7 +76,7 @@ function FlowMonthMarker({ label }: { label: string }) {
data-slot="document-flow-month"
className="absolute inset-x-0 -top-6 flex h-6 items-center gap-2"
>
<span className="text-muted-foreground shrink-0 truncate text-xs font-medium tracking-wide uppercase">
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
{label}
</span>
<span aria-hidden className="bg-border h-px flex-1" />
+41 -39
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -45,6 +45,13 @@ import {
type InsightsSectionConfig,
type InsightsSectionId,
} from "@/lib/insights-layout";
import {
arrangeSavePayload,
arrangementSignature,
reconcileArrangeDraft,
seedArrangeDraft,
sortByOrder,
} from "@/lib/insights-arrange-draft";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
@@ -66,8 +73,9 @@ import {
* Tile-level management (the per-metric detail pages + their nav pills)
* moved to Settings → Insights in v1.15.20 — the pill-order section there
* carries both sorting AND the eye toggles, so the disclosure this card used
* to nest under the Vitals row was a duplicate surface. The card keeps the
* draft's `tiles` untouched and links to the settings section instead.
* to nest under the Vitals row was a duplicate surface. A save sends the
* CURRENT server tiles (never a copy taken at mount), so a pill-order save
* made beside this card survives a later section save.
*/
/** Localized title key per section id — used for the edit-card label. */
@@ -118,16 +126,21 @@ export function InsightsEditMode({
const { t } = useTranslations();
const queryClient = useQueryClient();
// Local draft seeded from the resolved layout. Edits mutate the draft only;
// "Fertig" flushes it via the PUT mutation. Sections/tiles are sorted by
// order for stable rendering.
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
// Tiles pass through the save verbatim — pill order + visibility are
// managed on Settings → Insights since v1.15.20.
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
}));
// Local section draft seeded from the resolved layout. Edits mutate the
// draft only; "Fertig" flushes it via the PUT mutation. When the server copy
// changes underneath (the pill manager beside the Settings card also flips
// the "ecg" section), an untouched draft re-seeds in render — the
// React-sanctioned "adjust state on prop change" pattern — so it never turns
// someone else's save into a pending edit of its own.
const [draftState, setDraftState] = useState(() =>
seedArrangeDraft(layout.sections),
);
const reconciled = reconcileArrangeDraft(draftState, layout.sections);
if (reconciled) setDraftState(reconciled);
const draftSections = (reconciled ?? draftState).sections;
const setDraftSections = (
update: (sections: InsightsSectionConfig[]) => InsightsSectionConfig[],
) => setDraftState((d) => ({ ...d, sections: update(d.sections) }));
// v1.15.11 QA L5 — on mount move focus to the edit-card heading so keyboard /
// screen-reader users land on the surface they just opened (not the top of
@@ -147,14 +160,14 @@ export function InsightsEditMode({
);
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
mutationFn: async (sections: InsightsSectionConfig[]) => {
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
// was based on so an interleaved write (a Settings pill-order Save, or
// this surface open in another tab) 409s instead of clobbering.
return apiPut<InsightsLayoutWithToken>(
"/api/insights/layout",
withBaseToken(
{ version: 2, sections: next.sections, tiles: next.tiles },
arrangeSavePayload(sections, layout),
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
),
);
@@ -196,11 +209,7 @@ export function InsightsEditMode({
queryKey: queryKeys.insightsLayout(),
});
// Re-seed the draft so the open editor reflects the restored defaults.
setDraft({
version: saved.version,
sections: [...saved.sections].sort((a, b) => a.order - b.order),
tiles: [...saved.tiles].sort((a, b) => a.order - b.order),
});
setDraftState(seedArrangeDraft(saved.sections));
toast.success(t("insights.editMode.resetSuccess"));
},
onError: () => toast.error(t("insights.editMode.saveError")),
@@ -210,38 +219,31 @@ export function InsightsEditMode({
// The Settings card's Save waits for a change, like the pill-order Save
// beside it; the overview's inline editor keeps "Done" live because it
// also closes the editor.
const arrangement = (rows: readonly InsightsSectionConfig[]) =>
[...rows]
.sort((a, b) => a.order - b.order)
.map((r) => `${r.id}:${r.visible ? 1 : 0}`)
.join(",");
const dirty = arrangement(draft.sections) !== arrangement(layout.sections);
const dirty =
arrangementSignature(draftSections) !==
arrangementSignature(layout.sections);
const sections = useMemo(
() => [...draft.sections].sort((a, b) => a.order - b.order),
[draft.sections],
);
const sections = sortByOrder(draftSections);
const sectionIds = sections.map((s) => s.id);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
sections: d.sections.map((s) => (s.id === id ? { ...s, visible } : s)),
}));
setDraftSections((rows) =>
rows.map((s) => (s.id === id ? { ...s, visible } : s)),
);
}
function handleSectionDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const reordered = reorderById<InsightsSectionConfig>(
draft.sections,
draftSections,
String(active.id),
String(over.id),
);
setDraft((d) => ({ ...d, sections: reordered }));
setDraftSections(() => reordered);
}
const allHidden = draft.sections.every((s) => !s.visible);
const allHidden = draftSections.every((s) => !s.visible);
return (
<SettingsCard data-slot="insights-edit-mode">
@@ -282,7 +284,7 @@ export function InsightsEditMode({
/>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
onClick={() => saveMutation.mutate(draftSections)}
disabled={busy}
data-slot="insights-edit-done"
>
@@ -356,7 +358,7 @@ export function InsightsEditMode({
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(draft)}
onClick={() => saveMutation.mutate(draftSections)}
disabled={busy || !dirty}
data-slot="insights-edit-done"
>
@@ -559,3 +559,16 @@ describe("<SidebarNav> width transition never overflows the nav", () => {
);
});
});
describe("<SidebarNav> carries no rules for a viewport it never shows on", () => {
it("has no short-touch variants, which the phone shell owns", () => {
// A touch screen at most 520 px tall gets the phone shell (the
// `shell-mobile` variant), so the rail is hidden there and a class keyed
// on that query can never apply.
const source = readFileSync(
join(__dirname, "..", "sidebar-nav.tsx"),
"utf8",
);
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
});
});
+4 -15
View File
@@ -334,20 +334,15 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet,
// a phone held sideways past the md breakpoint) the rows keep the 44 px
// touch floor and the list scrolls behind its fades instead.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
// Literal class strings: Tailwind only generates what it can read whole.
const RAIL_SHORT_TOUCH_COLUMN =
"[@media(pointer:coarse)_and_(height<=520px)]:overflow-y-auto [@media(pointer:coarse)_and_(height<=520px)]:[scrollbar-width:none] [@media(pointer:coarse)_and_(height<=520px)]:[&::-webkit-scrollbar]:hidden [@media(pointer:coarse)_and_(height<=520px)]:[&>*]:shrink-0";
const RAIL_SHORT_TOUCH_NAV =
"[@media(pointer:coarse)_and_(height<=520px)]:flex-none [@media(pointer:coarse)_and_(height<=520px)]:overflow-visible";
/** Rail row, collapsed (icon only) or expanded (icon + label). */
function railRowClass(collapsed: boolean): string {
return cn(
@@ -698,11 +693,6 @@ export function SidebarNav() {
data-slot="sidebar-column"
className={cn(
"flex h-full w-full flex-col",
// A phone held sideways crosses the md breakpoint at ~390 px of
// height: the fixed footer and user rows left the list three
// rows. There the whole column scrolls as one (no native bar),
// so every entry stays reachable at the 44 px touch floor.
RAIL_SHORT_TOUCH_COLUMN,
!collapsed && SIDEBAR_EXPANDED_CONTENT_WIDTH,
)}
>
@@ -743,7 +733,6 @@ export function SidebarNav() {
// the focused row clear of them.
className={cn(
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
RAIL_SHORT_TOUCH_NAV,
collapsed ? "p-1.5" : "p-3",
RAIL_SHORT_PY,
)}
@@ -5,6 +5,7 @@ import {
MEASUREMENT_TYPE_ICONS,
MEASUREMENT_TYPE_COLORS,
measurementUnitLabel,
measurementUnitMetaLead,
} from "../measurement-list-meta";
import enMessages from "../../../../messages/en.json";
import deMessages from "../../../../messages/de.json";
@@ -61,3 +62,14 @@ describe("measurementUnitLabel", () => {
expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe("");
});
});
describe("measurementUnitMetaLead", () => {
const t = (key: string) => `t:${key}`;
it("leads the meta line with the unit and its separator", () => {
expect(measurementUnitMetaLead("WEIGHT", "kg", t)).toBe("kg · ");
});
it("leaves no bare separator for a score or a rating", () => {
expect(measurementUnitMetaLead("PHQ9_SCORE", "score", t)).toBeNull();
expect(measurementUnitMetaLead("VISCERAL_FAT", "rating", t)).toBeNull();
});
});
@@ -425,3 +425,17 @@ export function measurementUnitLabel(
}
const UNITLESS_TOKENS: ReadonlySet<string> = new Set(["score", "rating"]);
/**
* The unit as the lead of a row's meta line ("kg · 2 days ago"), or null
* when the row has no unit word to show: a score or a rating would otherwise
* open the line with a bare separator.
*/
export function measurementUnitMetaLead(
type: string,
unit: string,
t: (key: string) => string,
): string | null {
const label = measurementUnitLabel(type, unit, t);
return label ? `${label} · ` : null;
}
@@ -105,6 +105,7 @@ import {
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
measurementUnitLabel,
measurementUnitMetaLead,
} from "./measurement-list-meta";
import {
measurementListFiltersToSearch,
@@ -1537,19 +1538,22 @@ export function MeasurementList({
one ("Atemzüge/min") beside the value pushed
the row's controls; it leads the meta line
instead. Sleep reads as a duration. */}
{isSleep ? null : (
<span
data-slot="measurement-row-unit"
className="whitespace-nowrap"
>
{measurementUnitLabel(
m.type,
isGrouped ? m.unit : rowDisplay(m).unit,
t,
)}
{" · "}
</span>
)}
{(() => {
if (isSleep) return null;
const lead = measurementUnitMetaLead(
m.type,
isGrouped ? m.unit : rowDisplay(m).unit,
t,
);
return lead ? (
<span
data-slot="measurement-row-unit"
className="whitespace-nowrap"
>
{lead}
</span>
) : null;
})()}
{m.dayKey !== undefined ? (
<DayLink
date={m.dayKey}
@@ -32,9 +32,11 @@ import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
@@ -406,6 +408,25 @@ export function EfficacyChart({
insetLeft={40}
insetRight={8}
/>
{/* The keyboard and screen-reader way to a day: the plot opens days on
a click or a tap only. Each row stands at its day's anchor, the
calendar day the plot's own door opens. */}
{chartDays.active ? (
<ChartDataTable
points={displaySeries.map((point, index) => ({
date: seriesDays[index]!,
timestamp: dayAnchor(seriesDays[index]!),
value: point.value,
}))}
columns={[{ key: "value", label: target.label }]}
unit={unitSuffix ?? undefined}
formatValue={(value) => String(value)}
formatDate={fmt.date}
bucket="day"
metricLabel={target.label}
dayLinks
/>
) : null}
</div>
);
}
+3 -2
View File
@@ -392,8 +392,9 @@ function OfflineMutationToaster() {
* Below 600 px Sonner lays toasts across the bottom edge, 16 px up, which on
* a phone is on top of the bottom bar: the "new version" hint sat over the
* tabs and the add button until it was dismissed. The mobile offset lifts
* them above the bar and the home indicator. From `md` the bar is gone and
* the desktop offset applies.
* them above the bar and the home indicator. Sonner applies it below 600 px
* only, while the bar follows the shell (up to `md`, and a phone held
* sideways), so `globals.css` repeats the lift under `shell-mobile`.
*/
function AppToaster() {
const { resolvedTheme } = useTheme();
@@ -21,7 +21,7 @@
* Plain text only — the value renders exclusively through the input; no
* markdown anywhere.
*/
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -50,7 +50,15 @@ interface AboutMeData {
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AllergyFreeTextNote() {
export function AllergyFreeTextNote({
leadingAction,
}: {
/**
* The card's other action (the allergy list's add button), placed before
* Save so the card ends in one action row (design standards §12).
*/
leadingAction?: ReactNode;
} = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
@@ -121,12 +129,18 @@ export function AllergyFreeTextNote() {
onChange={(e) => setDraft(e.target.value)}
/>
{query.isError ? (
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
<>
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
{leadingAction ? (
<SettingsCardActions>{leadingAction}</SettingsCardActions>
) : null}
</>
) : (
<SettingsCardActions>
{leadingAction}
<Button
type="button"
size="sm"
+31 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Plus, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
@@ -24,8 +24,18 @@ import type { AllergyDTO } from "@/lib/records/dto";
* v1.25 (W-RECORDS) — the structured allergy/intolerance manager. Renders in
* the Settings → Anamnese section: a list of records with add / edit / delete.
* Patient-reported reference data, not a clinical diagnosis.
*
* `afterList` lets the card put something between the list and the action
* row (the free-text note in the Anamnesis card) while keeping ONE action
* row as the card's last element (design standards §12): it receives the
* add button (null while the empty state offers its own) and renders the
* row itself. Without it the manager closes with its own row.
*/
export function AllergyManager() {
export function AllergyManager({
afterList,
}: {
afterList?: (addAction: ReactNode | null) => ReactNode;
} = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
@@ -119,6 +129,19 @@ export function AllergyManager() {
);
}
const addAction =
!isLoading && !isError && rows.length > 0 ? (
<Button
size="sm"
variant="outline"
onClick={openNew}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
) : null;
return (
<div className="space-y-4">
{isLoading ? (
@@ -146,21 +169,12 @@ export function AllergyManager() {
)}
{/* The add action closes the list (design standards §12); an empty
list leaves it to the empty state's own button. Outline here,
because the card's primary is the free-text note's Save below. */}
{!isLoading && !isError && rows.length > 0 ? (
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={openNew}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
</SettingsCardActions>
) : null}
list leaves it to the empty state's own button. Outline, because
where the card carries the free-text note its Save is the primary
in the same row. */}
{afterList
? afterList(addAction)
: addAction && <SettingsCardActions>{addAction}</SettingsCardActions>}
<ResponsiveSheet
open={formOpen}
@@ -1,3 +1,4 @@
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
@@ -38,10 +39,30 @@ vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => <div data-slot="allergy-manager-probe" />,
AllergyManager: ({
afterList,
}: {
afterList?: (add: React.ReactNode) => React.ReactNode;
}) => (
<div data-slot="allergy-manager-probe">
{afterList ? (
afterList(<button data-slot="allergy-add-probe" />)
) : (
<div data-slot="allergy-manager-own-actions" />
)}
</div>
),
}));
vi.mock("@/components/records/allergy-free-text-note", () => ({
AllergyFreeTextNote: () => <div data-slot="allergy-free-text-probe" />,
AllergyFreeTextNote: ({
leadingAction,
}: {
leadingAction?: React.ReactNode;
}) => (
<div data-slot="allergy-free-text-probe">
<div data-slot="settings-card-actions">{leadingAction}</div>
</div>
),
}));
vi.mock("@/components/records/about-me-note-manager", () => ({
AboutMeNoteManager: () => <div data-slot="about-me-note-probe" />,
@@ -88,6 +109,19 @@ describe("Anamnese — the About-me note card (#159)", () => {
expect(between).not.toContain("<h2");
});
it("ends the allergies card in one action row carrying the add action", () => {
// The list's add button rides the note's row (design standards §12):
// the manager renders no row of its own, and the note's row holds add.
const html = renderSection();
expect(html).not.toContain('data-slot="allergy-manager-own-actions"');
const note = html.slice(
html.indexOf('data-slot="allergy-free-text-probe"'),
);
expect(note).toMatch(
/data-slot="settings-card-actions"><button data-slot="allergy-add-probe"/,
);
});
it("keeps the same AI-surface gate as the inclusion card", () => {
moduleState.coach = false;
moduleState.insights = false;
@@ -0,0 +1,33 @@
/**
* A card's action row is its last element (design standards §12). The
* family-history card on a shared record carries a sentence about relatives;
* it sits above the list, whose manager closes with the card's actions.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => (
<div data-slot="settings-card-actions">family-actions</div>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { RecordAnamnesisSection } from "../record-anamnesis-section";
describe("<RecordAnamnesisSection> card order", () => {
it("puts the relatives sentence above the family list and its actions", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecordAnamnesisSection />
</I18nProvider>,
);
const note = html.indexOf('data-slot="record-anamnesis-relative-note"');
const actions = html.indexOf("family-actions");
expect(note).toBeGreaterThan(-1);
expect(actions).toBeGreaterThan(note);
});
});
@@ -45,6 +45,26 @@ describe("Settings → Source priority, sources in use", () => {
expect(html).toContain('aria-expanded="false"');
});
it("names an element with every toggle's aria-controls, open or not", () => {
const html = render(["WITHINGS", "MANUAL"]);
const controls = [...html.matchAll(/aria-controls="([^"]+)"/g)].map(
(m) => m[1]!,
);
expect(controls.length).toBeGreaterThan(1);
for (const id of controls) expect(html).toContain(`id="${id}"`);
});
it("lets a collapsed group's winner truncate instead of squeezing the label", () => {
const html = render(["WITHINGS", "MANUAL"]);
const winner = html.match(
/data-slot="sources-metric-winner" class="([^"]*)"/,
)?.[1];
expect(winner).toBeDefined();
expect(winner).toMatch(/\btruncate\b/);
expect(winner).toMatch(/\bmin-w-0\b/);
expect(winner).not.toMatch(/\bshrink-0\b/);
});
it("says so when no source is in use at all", () => {
expect(render([])).toContain('data-testid="sources-none-in-use"');
});
@@ -111,11 +111,13 @@ export function AnamnesisSection() {
title={t("records.allergies.cardTitle")}
description={t("records.allergies.cardDescription")}
/>
<AllergyManager />
{/* #159 — the free-text supplement to the structured list above,
moved here from the account-settings "About me" panel so both
allergy inputs live in one card. */}
<AllergyFreeTextNote />
allergy inputs live in one card. The list's add button rides the
note's action row, so the card ends in one row. */}
<AllergyManager
afterList={(add) => <AllergyFreeTextNote leadingAction={add} />}
/>
</SettingsCard>
<SettingsCard>
@@ -36,8 +36,8 @@ import { useTranslations } from "@/lib/i18n/context";
* relatives, and often enough the delegate IS that relative, asserting a
* condition about themselves into somebody else's record. The route's own
* comment named that discomfort and left it for the surface to say out loud,
* because no route comment can. The sentence under the card is where it gets
* said.
* because no route comment can. The sentence at the top of the card body,
* above the list, is where it gets said.
*
* The cache is not a concern here and the reason is worth naming: switching
* records clears the whole query client, so the allergy and family-history
@@ -63,13 +63,15 @@ export function RecordAnamnesisSection() {
title={t("records.family.cardTitle")}
description={t("records.family.cardDescription")}
/>
<FamilyHistoryManager />
{/* Above the list: the manager closes with its action row, and
nothing follows a card's actions (design standards §12). */}
<p
data-slot="record-anamnesis-relative-note"
className="text-foreground text-sm"
>
{t("recordSharing.anamnesis.relativeNote")}
</p>
<FamilyHistoryManager />
</SettingsCard>
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
+181 -157
View File
@@ -415,60 +415,77 @@ export function SourcesSection() {
</span>
{/* Collapsed, the group still says who wins. */}
{!open && list[0] ? (
<span className="text-muted-foreground shrink-0 truncate text-xs">
<span
data-slot="sources-metric-winner"
// `truncate` needs room to give up: a shrink-0 span
// never cut, and a long source name squeezed the
// metric label at 390 px instead.
className="text-muted-foreground max-w-[45%] min-w-0 truncate text-xs"
>
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[list[0]])}
</span>
) : null}
</button>
{open ? (
<ul id={panelId} className="space-y-1 px-3 pb-3">
{list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
{/* The list stays in the tree, empty and hidden, while
the group is closed, so the toggle's aria-controls
always names an element. */}
<ul
id={panelId}
hidden={!open}
className="space-y-1 px-3 pb-3"
>
{open
? list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(
MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source],
)}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
) : null}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t(
"settings.sections.sources.moveUp",
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))
: null}
</ul>
</div>
);
})}
@@ -497,46 +514,49 @@ export function SourcesSection() {
count: overriddenMetrics.length,
})}
</button>
{showPerMetric && (
<div
id="sources-per-metric-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.perMetricHelp")}
</p>
{overriddenMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.perMetricEmpty")}
/>
) : (
<ul className="space-y-2">
{overriddenMetrics.map((metric) => (
<li
key={metric}
className="border-border bg-card flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<span className="text-sm">
{t(METRIC_LABEL_KEYS[metric])}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMetricToDefault(metric)}
disabled={saveMutation.isPending}
<div
id="sources-per-metric-panel"
role="region"
hidden={!showPerMetric}
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
{showPerMetric ? (
<>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.perMetricHelp")}
</p>
{overriddenMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.perMetricEmpty")}
/>
) : (
<ul className="space-y-2">
{overriddenMetrics.map((metric) => (
<li
key={metric}
className="border-border bg-card flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
{t("settings.sections.sources.resetMetric")}
</Button>
</li>
))}
</ul>
)}
</div>
)}
<span className="text-sm">
{t(METRIC_LABEL_KEYS[metric])}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMetricToDefault(metric)}
disabled={saveMutation.isPending}
>
{t("settings.sections.sources.resetMetric")}
</Button>
</li>
))}
</ul>
)}
</>
) : null}
</div>
</div>
)}
@@ -563,78 +583,82 @@ export function SourcesSection() {
count: deviceTypeOverrideCount,
})}
</button>
{showDeviceType && (
<div
id="sources-device-type-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.deviceTypeHelp")}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("settings.sections.sources.deviceTypeDefault")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetDeviceTypeAxis}
disabled={saveMutation.isPending}
>
<RotateCcw className="h-3 w-3" />
{t("settings.sections.sources.resetDeviceTypes")}
</Button>
</div>
<ul className="space-y-1">
{(
priority.deviceTypePriority.default ??
DEFAULT_DEVICE_TYPE_PRIORITY
).map((deviceType, index, list) => (
<li
key={`device-default-${deviceType}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
<div
id="sources-device-type-panel"
role="region"
hidden={!showDeviceType}
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
{showDeviceType ? (
<>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.deviceTypeHelp")}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("settings.sections.sources.deviceTypeDefault")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetDeviceTypeAxis}
disabled={saveMutation.isPending}
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
</span>
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
<RotateCcw className="h-3 w-3" />
{t("settings.sections.sources.resetDeviceTypes")}
</Button>
</div>
<ul className="space-y-1">
{(
priority.deviceTypePriority.default ??
DEFAULT_DEVICE_TYPE_PRIORITY
).map((deviceType, index, list) => (
<li
key={`device-default-${deviceType}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
</span>
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
as the metric source rows above. */}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</>
) : null}
</div>
</div>
)}
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RevealWhenSettled } from "../reveal-when-settled";
import { RevealWhenSettled, scrollToHashTarget } from "../reveal-when-settled";
/**
* The body mounts at once (so its reads start) but stays out of layout until
@@ -25,3 +25,44 @@ describe("<RevealWhenSettled>", () => {
);
});
});
describe("scrollToHashTarget", () => {
function setup(inside: boolean) {
const target = { scrollIntoView: vi.fn() };
const container = { contains: vi.fn(() => inside) };
const doc = {
getElementById: vi.fn((id: string) =>
id === "insights-pill-order" ? target : null,
),
};
return {
target,
container,
doc: doc as unknown as Pick<Document, "getElementById">,
};
}
it("scrolls to a hash target the revealed body holds", () => {
const { target, container, doc } = setup(true);
expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe(
true,
);
expect(target.scrollIntoView).toHaveBeenCalledWith({ block: "start" });
});
it("leaves a target outside the body to the browser", () => {
const { target, container, doc } = setup(false);
expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe(
false,
);
expect(target.scrollIntoView).not.toHaveBeenCalled();
});
it("does nothing without a hash or a target", () => {
const { container, doc } = setup(true);
expect(scrollToHashTarget(container, "", doc)).toBe(false);
expect(scrollToHashTarget(container, "#", doc)).toBe(false);
expect(scrollToHashTarget(container, "#nope", doc)).toBe(false);
expect(scrollToHashTarget(container, "#%E0%A4%A", doc)).toBe(false);
});
});
+5 -2
View File
@@ -257,7 +257,10 @@ export function ResponsiveSheet({
// Without a footer the body is the sheet's bottom edge, so it
// keeps its last row above the home indicator of an
// installed app; the footer does that when there is one.
!footer && "pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
// Standing on the keyboard, the sheet is clear of the
// indicator already; the inset would leave a gap above it.
!footer &&
"keyboard-open:pb-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
bodyClassName,
)}
>
@@ -266,7 +269,7 @@ export function ResponsiveSheet({
{footer ? (
<SheetFooter
data-slot="responsive-sheet-footer"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 keyboard-open:pb-4 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
>
{footer}
</SheetFooter>
+34 -1
View File
@@ -1,8 +1,30 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
/**
* Scroll to the element the URL's hash names, when it sits inside
* `container`. Returns whether it scrolled.
*/
export function scrollToHashTarget(
container: Pick<Element, "contains"> | null,
hash: string,
doc: Pick<Document, "getElementById">,
): boolean {
if (!container || hash.length < 2) return false;
let id: string;
try {
id = decodeURIComponent(hash.slice(1));
} catch {
return false;
}
const target = doc.getElementById(id);
if (!target || !container.contains(target)) return false;
target.scrollIntoView({ block: "start" });
return true;
}
/**
* Holds a page body out of layout until its first reads have settled, then
* shows it in one step.
@@ -21,6 +43,10 @@ import { useIsFetching, useQueryClient } from "@tanstack/react-query";
* slow or polling read elsewhere in the app can delay it but never strand
* it. Revealing is latched: a later background refetch does not hide the
* body again.
*
* A deep link into the body (`/settings/layout/insights#insights-pill-order`)
* found its target hidden on arrival, so the browser had nothing to scroll
* to. Once the body shows, it scrolls to the hash target itself.
*/
export function RevealWhenSettled({
children,
@@ -39,6 +65,12 @@ export function RevealWhenSettled({
const queryClient = useQueryClient();
const fetching = useIsFetching();
const [revealed, setRevealed] = useState(false);
const bodyRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!revealed) return;
scrollToHashTarget(bodyRef.current, window.location.hash, document);
}, [revealed]);
useEffect(() => {
if (revealed || fetching > 0) return;
@@ -67,6 +99,7 @@ export function RevealWhenSettled({
<>
{revealed ? null : fallback}
<div
ref={bodyRef}
data-slot="reveal-when-settled"
data-revealed={revealed ? "true" : "false"}
aria-busy={revealed ? undefined : true}
+4 -1
View File
@@ -66,6 +66,9 @@ function SheetContent({
// full-height sides keep clear of the status bar and the notch.
// Its own attribute rather than `data-slot`, which consumers
// replace with theirs (`responsive-sheet-content`, `day-panel`).
// A bottom sheet's `bottom` comes from that rule (0 until the
// keyboard opens), so its branch below carries no `bottom-*`: a
// utility would outrank the layered rule and pin it to the edge.
data-sheet-side={side}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -76,7 +79,7 @@ function SheetContent({
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t",
className,
)}
{...props}
+22 -3
View File
@@ -101,6 +101,27 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
// to break the loop within one document lifetime.
let inMemoryReloadGuard: string | null = null;
/**
* The worker caches the "new version" reload clears: every HealthLog cache
* except the target release's own. `registration.update()` resolves as soon
* as the new worker starts installing, so its freshly precached logos and
* favicon (`healthlog-static-v<target>`) may already exist by then; deleting
* them left the installed app without them offline until they were fetched
* again. Cache names carry the version with a `v`, the version endpoint may
* answer without one.
*/
export function cachesToEvict(
keys: readonly string[],
targetVersion: string,
): string[] {
const target = targetVersion.replace(/^v/, "");
return keys.filter((key) => {
if (!HEALTHLOG_CACHE_NAME_RE.test(key)) return false;
const version = key.replace(HEALTHLOG_CACHE_NAME_RE, "").replace(/^v/, "");
return version !== target;
});
}
export async function evictAndReload(targetVersion: string): Promise<void> {
inMemoryReloadGuard = targetVersion;
try {
@@ -123,9 +144,7 @@ export async function evictAndReload(targetVersion: string): Promise<void> {
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
.map((k) => caches.delete(k)),
cachesToEvict(keys, targetVersion).map((k) => caches.delete(k)),
);
} catch {
/* best effort */
@@ -0,0 +1,92 @@
/**
* The overview arrange editor and the pill manager share one layout blob.
* A pill save followed by a section save must keep the pill order, and an
* untouched section draft must follow the server copy (the pill manager also
* flips the "ecg" section) instead of reading as a pending edit.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsLayout,
} from "@/lib/insights-layout";
import {
arrangeSavePayload,
arrangementSignature,
reconcileArrangeDraft,
seedArrangeDraft,
} from "@/lib/insights-arrange-draft";
function clone(layout: InsightsLayout): InsightsLayout {
return {
version: layout.version,
sections: layout.sections.map((s) => ({ ...s })),
tiles: layout.tiles.map((t) => ({ ...t })),
};
}
/** The layout after a pill save: tile order reversed, ECG section hidden. */
function afterPillSave(base: InsightsLayout): InsightsLayout {
const next = clone(base);
const n = next.tiles.length;
next.tiles = next.tiles.map((t) => ({ ...t, order: n - 1 - t.order }));
next.sections = next.sections.map((s) =>
s.id === "ecg" ? { ...s, visible: false } : s,
);
return next;
}
describe("insights arrange draft", () => {
it("keeps a pill order saved after the editor mounted", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const server = afterPillSave(mounted);
// The user then moves a section and saves.
const reconciled = reconcileArrangeDraft(draft, server.sections) ?? draft;
const moved = reconciled.sections.map((s, i, all) =>
i === 0 ? { ...s, order: all.length } : { ...s, order: s.order - 1 },
);
const body = arrangeSavePayload(moved, server);
expect(body.tiles).toEqual(server.tiles);
expect(body.tiles).not.toEqual(mounted.tiles);
// The ECG flip from the pill manager survives too.
expect(body.sections.find((s) => s.id === "ecg")?.visible).toBe(false);
});
it("re-seeds an untouched draft so a pill-manager ECG flip is not dirty", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const server = afterPillSave(mounted);
const next = reconcileArrangeDraft(draft, server.sections);
expect(next).not.toBeNull();
expect(arrangementSignature(next!.sections)).toBe(
arrangementSignature(server.sections),
);
});
it("keeps the user's own edits when the server copy moves", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const edited = {
...draft,
sections: draft.sections.map((s) =>
s.id === "trends" ? { ...s, visible: !s.visible } : s,
),
};
const server = afterPillSave(mounted);
const next = reconcileArrangeDraft(edited, server.sections);
expect(next?.sections).toEqual(edited.sections);
expect(next?.seeded).toBe(arrangementSignature(server.sections));
});
it("does nothing while the server copy is unchanged", () => {
const draft = seedArrangeDraft(DEFAULT_INSIGHTS_LAYOUT.sections);
expect(
reconcileArrangeDraft(draft, DEFAULT_INSIGHTS_LAYOUT.sections),
).toBeNull();
});
});
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { settleWithin } from "../settle-within";
afterEach(() => {
vi.useRealTimers();
});
describe("settleWithin", () => {
it("answers with the work when it lands inside the budget, and leaves no timer", async () => {
vi.useFakeTimers();
await expect(
settleWithin(Promise.resolve("done"), 1_500, null),
).resolves.toBe("done");
// A bare race left the budget timer pending after the work answered.
expect(vi.getTimerCount()).toBe(0);
});
it("answers with the fallback once the budget runs out", async () => {
vi.useFakeTimers();
const pending = settleWithin(new Promise<string>(() => {}), 1_500, null);
await vi.advanceTimersByTimeAsync(1_500);
await expect(pending).resolves.toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it("passes a rejection inside the budget through, and clears the timer", async () => {
vi.useFakeTimers();
await expect(
settleWithin(Promise.reject(new Error("boom")), 1_500, null),
).rejects.toThrow("boom");
expect(vi.getTimerCount()).toBe(0);
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Wait for `work` at most `ms`, then settle with `fallback` instead.
*
* The budget timer is cleared as soon as either side settles. A bare
* `Promise.race` against a `setTimeout` left the timer running after the
* work had long answered: one live timer per server render or per audited
* sign-in, each holding its closure until it fired. `work` is not
* cancelled; a rejection of `work` inside the budget rejects here too.
*/
export async function settleWithin<T, F>(
work: Promise<T>,
ms: number,
fallback: F,
): Promise<T | F> {
let timer: ReturnType<typeof setTimeout> | undefined;
const budget = new Promise<F>((resolve) => {
timer = setTimeout(() => resolve(fallback), ms);
});
try {
return await Promise.race([work, budget]);
} finally {
clearTimeout(timer);
}
}
+2 -5
View File
@@ -1,3 +1,4 @@
import { settleWithin } from "@/lib/async/settle-within";
import { prisma } from "@/lib/db";
import { lookupIpGeo } from "@/lib/geo";
import { getEvent } from "@/lib/logging/context";
@@ -80,11 +81,7 @@ export async function auditLog(
// provider can't stall the audit write.
if (opts.ipAddress && action.startsWith("auth.")) {
const ipAddress = opts.ipAddress;
const timeout = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000),
);
Promise.race([lookupIpGeo(ipAddress), timeout])
settleWithin(lookupIpGeo(ipAddress), 3000, null)
.then((result) => {
if (!result) return;
const { location, asn, carrier } = result;
+81
View File
@@ -0,0 +1,81 @@
/**
* Pure draft logic for the Insights overview arrange editor.
*
* The editor owns `sections` (order + visibility) only. The pill manager on
* Settings → Insights writes the same `/api/insights/layout` blob, so a save
* here must carry the CURRENT server tiles rather than a copy taken when the
* editor mounted: the base token is read from the shared cache at save time,
* so a stale tile copy would never 409 and would silently undo a pill save.
*
* For the same reason the section draft follows the server copy whenever the
* user has not touched it — the pill manager also flips the `"ecg"` section's
* visibility, and an untouched draft must not turn that into a pending edit.
*/
import type {
InsightsLayout,
InsightsSectionConfig,
InsightsTileConfig,
} from "@/lib/insights-layout";
export function sortByOrder<T extends { order: number }>(
rows: readonly T[],
): T[] {
return [...rows].sort((a, b) => a.order - b.order);
}
/** Order + visibility fingerprint of a section list. */
export function arrangementSignature(
rows: readonly InsightsSectionConfig[],
): string {
return sortByOrder(rows)
.map((r) => `${r.id}:${r.visible ? 1 : 0}`)
.join(",");
}
export interface ArrangeDraftState {
/** Server signature the draft was last seeded from. */
seeded: string;
sections: InsightsSectionConfig[];
}
export function seedArrangeDraft(
serverSections: readonly InsightsSectionConfig[],
): ArrangeDraftState {
return {
seeded: arrangementSignature(serverSections),
sections: sortByOrder(serverSections),
};
}
/**
* Follow a server-side change to the sections. Returns `null` when nothing
* needs to change. An untouched draft re-seeds; a draft carrying the user's
* own edits is kept, only the baseline advances.
*/
export function reconcileArrangeDraft(
state: ArrangeDraftState,
serverSections: readonly InsightsSectionConfig[],
): ArrangeDraftState | null {
const server = arrangementSignature(serverSections);
if (server === state.seeded) return null;
const untouched = arrangementSignature(state.sections) === state.seeded;
return untouched
? seedArrangeDraft(serverSections)
: { seeded: server, sections: state.sections };
}
/** The PUT body: the draft's sections over the current server tiles. */
export function arrangeSavePayload(
draftSections: readonly InsightsSectionConfig[],
current: Pick<InsightsLayout, "tiles">,
): {
version: 2;
sections: InsightsSectionConfig[];
tiles: InsightsTileConfig[];
} {
return {
version: 2,
sections: [...draftSections],
tiles: [...current.tiles],
};
}
@@ -352,6 +352,42 @@ describe("computeWellnessScore — strain reads the device's day strain", () =>
expect(r.provenance.inputs).toEqual(["DAY_STRAIN"]);
});
it("counts days, not cycles, when two cycles start on one local day", async () => {
// Bed at 00:10 and again at 23:50 the same Berlin day: two cycle rows,
// one calendar day, which the recovery chart shows as one point (the
// day's mean).
byType({
STRAIN_SCORE: [],
DAY_STRAIN: [
{
value: 4,
measuredAt: new Date("2026-06-01T21:50:00Z"),
source: "WHOOP",
},
{
value: 12,
measuredAt: new Date("2026-05-31T22:10:00Z"),
source: "WHOOP",
},
{
value: 9,
measuredAt: new Date("2026-05-30T21:30:00Z"),
source: "WHOOP",
},
],
});
const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status !== "ok") return;
const v = r.value as WellnessScoreValue;
expect(v.daysInWindow).toBe(2);
expect(v.series).toHaveLength(2);
expect(v.device).toEqual({ value: 8, scaleMax: 21 });
expect(r.coverage.historyDays).toBe(2);
});
it("keeps the computed proxy when one exists", async () => {
byType({
STRAIN_SCORE: [
+50 -1
View File
@@ -24,6 +24,8 @@ import { computeReadiness, type ReadinessComponent } from "./readiness";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
@@ -208,7 +210,18 @@ export async function computeWellnessScore(
orderBy: { measuredAt: "desc" },
});
if (deviceRows.length > 0) {
return buildDeviceStrain(deviceRows, windowDays, computedAt);
if (timezone === null) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
timezone = user?.timezone ?? null;
}
return buildDeviceStrain(
deviceStrainDays(deviceRows, timezone),
windowDays,
computedAt,
);
}
}
@@ -314,6 +327,42 @@ function deviceStrainShare(value: number): number {
return Math.round((clamped / DEVICE_STRAIN_SCALE_MAX) * 100);
}
/**
* One row per local calendar day from the band's DAY_STRAIN rows. The band
* writes one row per physiological cycle, stamped at the cycle's start, and
* a bedtime either side of midnight puts two cycle starts on one calendar
* day. Counted as rows, those days were counted twice and the sparkline
* carried an extra point the recovery page's daily chart does not have. The
* day's value is the mean of its rows, which is the chart's own daily value;
* the day stands at its latest row. Newest day first.
*/
export function deviceStrainDays(
rows: readonly { value: number; measuredAt: Date }[],
timezone: string | null,
): { value: number; measuredAt: Date }[] {
const tz = resolveUserTimezone(timezone);
const byDay = new Map<
string,
{ sum: number; count: number; measuredAt: Date }
>();
for (const row of rows) {
const key = dayKeyForUserTz(row.measuredAt, tz);
const day = byDay.get(key);
if (!day) {
byDay.set(key, { sum: row.value, count: 1, measuredAt: row.measuredAt });
continue;
}
day.sum += row.value;
day.count += 1;
if (row.measuredAt.getTime() > day.measuredAt.getTime()) {
day.measuredAt = row.measuredAt;
}
}
return [...byDay.values()]
.map((day) => ({ value: day.sum / day.count, measuredAt: day.measuredAt }))
.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime());
}
function buildDeviceStrain(
rows: { value: number; measuredAt: Date }[],
windowDays: number,
@@ -38,6 +38,21 @@ describe("getSourcesInUse", () => {
expect(await getSourcesInUse("u1")).toEqual(["MANUAL"]);
});
it("does not count a source whose readings were all deleted", async () => {
// The probe answers like the database: a MANUAL row exists, but it is a
// tombstone, so only a probe that admits deleted rows finds it.
vi.mocked(prisma.measurement.findFirst).mockImplementation(((args: {
where: { source: string; deletedAt?: null };
}) =>
Promise.resolve(
args.where.source === "MANUAL" && !("deletedAt" in args.where)
? { id: "m-deleted" }
: null,
)) as never);
expect(await getSourcesInUse("u1")).toEqual([]);
});
it("lists a connected integration that has not delivered yet", async () => {
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
+8 -2
View File
@@ -17,7 +17,8 @@ export const RANKABLE_SOURCES: readonly MeasurementSource[] = Array.from(
* one measurement from, or an integration it has connected that has not
* delivered yet.
*
* The Settings ladder lists only these. Ranking a source the person has never
* Deleted (tombstoned) readings do not count. The Settings ladder lists only
* these. Ranking a source the person has never
* used decides nothing, and ten of them per metric buried the two that do.
* The ladders themselves stay whole: hidden sources keep their slot, so a
* source that starts delivering later lands where the ladder already put it.
@@ -33,7 +34,12 @@ export async function getSourcesInUse(
Promise.all(
RANKABLE_SOURCES.map((source) =>
prisma.measurement
.findFirst({ where: { userId, source }, select: { id: true } })
.findFirst({
// A deleted reading is not data: a source whose readings were
// all deleted is no longer in use.
where: { userId, source, deletedAt: null },
select: { id: true },
})
.then((row) => (row ? source : null)),
),
),
+52 -1
View File
@@ -3,7 +3,11 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
import {
applyThemeColor,
THEME_BOOT_SCRIPT,
THEME_COLOR,
} from "@/lib/pwa/theme-color";
function fakeMeta(content: string) {
const attrs = new Map<string, string>([["content", content]]);
@@ -46,3 +50,50 @@ describe("applyThemeColor", () => {
);
});
});
describe("THEME_BOOT_SCRIPT", () => {
function boot(stored: string | null, systemDark: boolean) {
const metas = [fakeMeta(THEME_COLOR.light), fakeMeta(THEME_COLOR.dark)];
const classes = new Set<string>();
const listeners = new Map<string, () => void>();
const document = {
documentElement: { classList: { add: (c: string) => classes.add(c) } },
querySelectorAll: () => metas,
addEventListener: (type: string, fn: () => void) =>
listeners.set(type, fn),
};
const window = {
matchMedia: () => ({ matches: systemDark }),
};
const localStorage = { getItem: () => stored };
new Function("window", "document", "localStorage", THEME_BOOT_SCRIPT)(
window,
document,
localStorage,
);
return { metas, classes, listeners };
}
it("paints the status bar in the app's theme before hydration", () => {
// A light system, the app in its dark default: the light-keyed tag is
// the one the browser applies, so it must carry the dark colour already.
const { metas, classes } = boot(null, false);
expect([...classes]).toEqual(["dark"]);
for (const meta of metas) {
expect(meta.attrs.get("content")).toBe(THEME_COLOR.dark);
}
});
it("follows a stored light choice on a dark system", () => {
const { metas, classes } = boot("light", true);
expect([...classes]).toEqual(["light"]);
for (const meta of metas) {
expect(meta.attrs.get("content")).toBe(THEME_COLOR.light);
}
});
it("repeats the rewrite once the document is parsed", () => {
const { listeners } = boot("system", true);
expect(listeners.has("DOMContentLoaded")).toBe(true);
});
});
+11
View File
@@ -28,3 +28,14 @@ export function applyThemeColor(
meta.setAttribute("content", THEME_COLOR[resolved]);
}
}
/**
* The inline boot script the root layout runs before first paint. It stamps
* the theme class (no flash of the wrong theme) and rewrites the theme-color
* tags to that theme, which until now waited for hydration: a phone in light
* mode running the app in dark mode showed a light status bar until React
* caught up. The tags may stream in after the script, so it repeats the
* rewrite once the document is parsed. Built from constants only — the
* layout's one `dangerouslySetInnerHTML` stays file-literal content.
*/
export const THEME_BOOT_SCRIPT = `(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?${JSON.stringify(THEME_COLOR.light)}:${JSON.stringify(THEME_COLOR.dark)};function m(){var a=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()`;
@@ -72,6 +72,41 @@ describe("runSetDocumentsLayout", () => {
await run;
});
it("is not undone by a first read still in flight", async () => {
const queryClient = new QueryClient();
let answerRead: (value: DocumentsLayout) => void = () => {};
const read = queryClient
.fetchQuery({
queryKey: key,
queryFn: () =>
new Promise<DocumentsLayout>((resolve) => {
answerRead = resolve;
}),
})
.catch(() => undefined);
let settle: (value: DocumentsLayout) => void = () => {};
vi.mocked(apiPut).mockReturnValue(
new Promise<DocumentsLayout>((resolve) => {
settle = resolve;
}),
);
const run = runSetDocumentsLayout({
patch: { view: "list" },
queryClient,
t,
});
// The read answers with the stored layout while the save is pending.
answerRead(STORED);
await read;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(queryClient.getQueryData<DocumentsLayout>(key)?.view).toBe("list");
settle({ ...STORED, view: "list" });
await run;
expect(queryClient.getQueryData<DocumentsLayout>(key)?.view).toBe("list");
});
it("rolls back and says so when the save fails", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(key, STORED);
+7
View File
@@ -40,6 +40,13 @@ export async function runSetDocumentsLayout(deps: {
}): Promise<void> {
const { patch, queryClient, t } = deps;
const key = queryKeys.documentsLayout();
// A read still in flight (the first GET) would land after the flip and
// paint the old layout until the PUT answered. Cancel it first; only then
// is the flip safe, because a cancelled fetch reverts the cache as it
// settles. Without one in flight the flip stays synchronous.
if (queryClient.isFetching({ queryKey: key }) > 0) {
await queryClient.cancelQueries({ queryKey: key });
}
const previous = queryClient.getQueryData<DocumentsLayout>(key);
queryClient.setQueryData<DocumentsLayout>(key, {
...(previous ?? DEFAULT_DOCUMENTS_LAYOUT),
+61
View File
@@ -0,0 +1,61 @@
/**
* The sources the Settings ladder lists, against real Postgres.
*
* A source counts while the account holds a live reading from it. Deleting
* readings tombstones them (`deletedAt`), so the probe has to skip the
* tombstones or a source whose readings were all deleted stays marked as in
* use for good.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seed() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "sources-in-use",
email: "sources-in-use@example.test",
role: "USER",
},
});
const reading = (source: "MANUAL" | "APPLE_HEALTH", minutes: number) =>
prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
source,
measuredAt: new Date(Date.UTC(2026, 4, 12, 7, minutes)),
},
});
return { prisma, user, reading };
}
describe("getSourcesInUse", () => {
it("lists a source with a live reading", async () => {
const { user, reading } = await seed();
await reading("MANUAL", 0);
const { getSourcesInUse } =
await import("@/lib/integrations/sources-in-use");
expect(await getSourcesInUse(user.id)).toEqual(["MANUAL"]);
});
it("drops a source once every reading from it is deleted", async () => {
const { prisma, user, reading } = await seed();
const manual = await reading("MANUAL", 0);
await reading("APPLE_HEALTH", 1);
await prisma.measurement.update({
where: { id: manual.id },
data: { deletedAt: new Date() },
});
const { getSourcesInUse } =
await import("@/lib/integrations/sources-in-use");
expect(await getSourcesInUse(user.id)).toEqual(["APPLE_HEALTH"]);
});
});