mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge the interface review fixes into release/v1.42.0
This commit is contained in:
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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user