mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -27,6 +27,17 @@ function render(node: React.ReactNode) {
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function buttonMarkupByName(html: string, name: string): string {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const markup = html.match(
|
||||
new RegExp(
|
||||
`<button(?=[^>]*aria-label="${escapedName}")[^>]*>[\\s\\S]*?</button>`,
|
||||
),
|
||||
)?.[0];
|
||||
expect(markup).toBeDefined();
|
||||
return markup ?? "";
|
||||
}
|
||||
|
||||
const suggestion = (
|
||||
overrides: Partial<DocumentSuggestionDto> = {},
|
||||
): DocumentSuggestionDto => ({
|
||||
@@ -112,6 +123,27 @@ describe("<AssistSuggestionReview>", () => {
|
||||
);
|
||||
expect(html).toContain("Nothing could be read from this document");
|
||||
});
|
||||
|
||||
it("keeps dismiss compact while providing a 44px mobile target", () => {
|
||||
const html = render(
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion()}
|
||||
kindLabel="Lab result"
|
||||
dateLabel="1 Mar 2026"
|
||||
applied={{ title: false, kind: false, date: false }}
|
||||
onUseTitle={noop}
|
||||
onUseKind={noop}
|
||||
onUseDate={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
const dismiss = buttonMarkupByName(html, "Dismiss");
|
||||
expect(dismiss).toContain("size-11");
|
||||
expect(dismiss).toContain("sm:size-7");
|
||||
expect(dismiss).toContain('aria-label="Dismiss"');
|
||||
expect(dismiss).toContain("<svg");
|
||||
expect(dismiss).toContain("size-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentSummaryPanel>", () => {
|
||||
@@ -170,4 +202,22 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("read the document");
|
||||
});
|
||||
|
||||
it("keeps close compact while providing a 44px mobile target", () => {
|
||||
const html = render(
|
||||
<DocumentSummaryPanel
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
const close = buttonMarkupByName(html, "Hide");
|
||||
expect(close).toContain("size-11");
|
||||
expect(close).toContain("sm:size-7");
|
||||
expect(close).toContain('aria-label="Hide"');
|
||||
expect(close).toContain("<svg");
|
||||
expect(close).toContain("size-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
closeDocumentSelectionHistoryEntry,
|
||||
closeDocumentSelectionAfterCoachHandoff,
|
||||
documentSelectionHistoryState,
|
||||
documentSelectionHref,
|
||||
withoutDocumentSelectionHref,
|
||||
} from "../documents-view";
|
||||
|
||||
class MemoryHistory {
|
||||
private entries: { href: string; state: unknown }[];
|
||||
private index: number;
|
||||
|
||||
constructor(entries: string[]) {
|
||||
this.entries = entries.map((href) => ({ href, state: null }));
|
||||
this.index = entries.length - 1;
|
||||
}
|
||||
|
||||
get href() {
|
||||
return this.entries[this.index]?.href;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.entries[this.index]?.state;
|
||||
}
|
||||
|
||||
pushState(state: unknown, _unused: string, href: string | URL) {
|
||||
this.entries.splice(this.index + 1, Infinity, {
|
||||
href: href.toString(),
|
||||
state,
|
||||
});
|
||||
this.index += 1;
|
||||
}
|
||||
|
||||
replaceState(state: unknown, _unused: string, href?: string | URL | null) {
|
||||
this.entries[this.index] = {
|
||||
href: href?.toString() ?? this.href!,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
back() {
|
||||
if (this.index > 0) this.index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe("document vault URL selection", () => {
|
||||
it("adds an ordinary card selection without dropping unrelated query state", () => {
|
||||
expect(
|
||||
documentSelectionHref(
|
||||
"/documents",
|
||||
"episode=episode-1&view=compact&q=MRT",
|
||||
"doc_123",
|
||||
),
|
||||
).toBe("/documents?episode=episode-1&view=compact&q=MRT&doc=doc_123");
|
||||
});
|
||||
|
||||
it("replaces an existing selection instead of appending a duplicate", () => {
|
||||
expect(
|
||||
documentSelectionHref(
|
||||
"/documents",
|
||||
"episode=episode-1&doc=old-document",
|
||||
"new-document",
|
||||
),
|
||||
).toBe("/documents?episode=episode-1&doc=new-document");
|
||||
});
|
||||
|
||||
it.each(["", "../coach", "doc?id=other", "doc id", "doc\n", "a".repeat(41)])(
|
||||
"refuses to write an unsafe document id: %j",
|
||||
(documentId) => {
|
||||
expect(
|
||||
documentSelectionHref(
|
||||
"/documents",
|
||||
"episode=episode-1&view=compact",
|
||||
documentId,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("removes only the document selection on close", () => {
|
||||
expect(
|
||||
withoutDocumentSelectionHref(
|
||||
"/documents",
|
||||
"episode=episode-1&doc=doc_123&view=compact&q=MRT",
|
||||
),
|
||||
).toBe("/documents?episode=episode-1&view=compact&q=MRT");
|
||||
});
|
||||
|
||||
it("returns the pathname when doc was the only query parameter", () => {
|
||||
expect(withoutDocumentSelectionHref("/documents", "doc=doc_123")).toBe(
|
||||
"/documents",
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes a card-open entry so the next Back reaches the prior distinct page", () => {
|
||||
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
|
||||
const openHref = documentSelectionHref(
|
||||
"/documents",
|
||||
"episode=ep-1",
|
||||
"doc-1",
|
||||
);
|
||||
expect(openHref).not.toBeNull();
|
||||
history.pushState(documentSelectionHistoryState("doc-1"), "", openHref!);
|
||||
|
||||
closeDocumentSelectionHistoryEntry(
|
||||
history,
|
||||
"/documents",
|
||||
"episode=ep-1&doc=doc-1",
|
||||
"doc-1",
|
||||
);
|
||||
|
||||
expect(history.href).toBe("/documents?episode=ep-1");
|
||||
history.back();
|
||||
expect(history.href).toBe("/today");
|
||||
});
|
||||
|
||||
it("consumes a dismissed Coach handoff and closes its card-owned detail entry", () => {
|
||||
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
|
||||
history.pushState(
|
||||
documentSelectionHistoryState("doc-1"),
|
||||
"",
|
||||
"/documents?episode=ep-1&doc=doc-1",
|
||||
);
|
||||
|
||||
const retainedHandoff = closeDocumentSelectionAfterCoachHandoff(
|
||||
history,
|
||||
"/documents",
|
||||
"episode=ep-1&doc=doc-1",
|
||||
"doc-1",
|
||||
"doc-1",
|
||||
"dismiss",
|
||||
);
|
||||
|
||||
expect(retainedHandoff).toBeNull();
|
||||
expect(history.href).toBe("/documents?episode=ep-1");
|
||||
});
|
||||
|
||||
it("retains a navigating Coach handoff for maximize and browser Back", () => {
|
||||
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
|
||||
history.pushState(
|
||||
documentSelectionHistoryState("doc-1"),
|
||||
"",
|
||||
"/documents?episode=ep-1&doc=doc-1",
|
||||
);
|
||||
|
||||
const retainedHandoff = closeDocumentSelectionAfterCoachHandoff(
|
||||
history,
|
||||
"/documents",
|
||||
"episode=ep-1&doc=doc-1",
|
||||
"doc-1",
|
||||
"doc-1",
|
||||
"navigate",
|
||||
);
|
||||
|
||||
expect(retainedHandoff).toBe("doc-1");
|
||||
expect(history.href).toBe("/documents?episode=ep-1&doc=doc-1");
|
||||
});
|
||||
|
||||
it("replaces a direct deep-link selection without navigating away", () => {
|
||||
const history = new MemoryHistory([
|
||||
"/today",
|
||||
"/documents?episode=ep-1&doc=doc-1",
|
||||
]);
|
||||
history.replaceState({ __NA: true }, "", history.href);
|
||||
closeDocumentSelectionHistoryEntry(
|
||||
history,
|
||||
"/documents",
|
||||
"episode=ep-1&doc=doc-1",
|
||||
"doc-1",
|
||||
);
|
||||
|
||||
expect(history.href).toBe("/documents?episode=ep-1");
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ export function AssistSuggestionReview({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
className="size-11 shrink-0 sm:size-7"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("documents.assist.dismiss")}
|
||||
>
|
||||
@@ -233,7 +233,7 @@ export function DocumentSummaryPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
className="size-11 shrink-0 sm:size-7"
|
||||
onClick={onClose}
|
||||
aria-label={t("documents.summary.close")}
|
||||
>
|
||||
|
||||
@@ -42,6 +42,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
type CoachCloseIntent,
|
||||
useCoachLaunch,
|
||||
} from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
@@ -79,6 +83,99 @@ interface ListPage {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
export function documentSelectionHistoryState(
|
||||
documentId: string,
|
||||
): Record<string, unknown> {
|
||||
// Pass only application state here. Next's patched pushState copies its
|
||||
// private router fields onto this object; copying history.state ourselves
|
||||
// would include `__NA` and make that patch treat this as an internal write,
|
||||
// skipping useSearchParams synchronization.
|
||||
return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId };
|
||||
}
|
||||
|
||||
export function documentSelectionHref(
|
||||
pathname: string,
|
||||
currentSearch: string,
|
||||
documentId: string,
|
||||
): string | null {
|
||||
if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null;
|
||||
const next = new URLSearchParams(currentSearch);
|
||||
next.set("doc", documentId);
|
||||
const search = next.toString();
|
||||
return search ? `${pathname}?${search}` : pathname;
|
||||
}
|
||||
|
||||
export function withoutDocumentSelectionHref(
|
||||
pathname: string,
|
||||
currentSearch: string,
|
||||
): string {
|
||||
const next = new URLSearchParams(currentSearch);
|
||||
next.delete("doc");
|
||||
const search = next.toString();
|
||||
return search ? `${pathname}?${search}` : pathname;
|
||||
}
|
||||
|
||||
export function closeDocumentSelectionHistoryEntry(
|
||||
history: {
|
||||
readonly state: unknown;
|
||||
back: () => void;
|
||||
replaceState: (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => void;
|
||||
},
|
||||
pathname: string,
|
||||
currentSearch: string,
|
||||
documentId: string,
|
||||
): void {
|
||||
const ownsCurrentEntry =
|
||||
history.state !== null &&
|
||||
typeof history.state === "object" &&
|
||||
(history.state as Record<string, unknown>)[
|
||||
DOCUMENT_SELECTION_HISTORY_KEY
|
||||
] === documentId;
|
||||
if (ownsCurrentEntry) {
|
||||
// Card selection owns the current pushed entry. Returning to its base
|
||||
// consumes it, so the user's next Back reaches the prior distinct page.
|
||||
history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// A deep link did not create a disposable entry on this page. Keep the user
|
||||
// here and remove only the selection from the current URL.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
withoutDocumentSelectionHref(pathname, currentSearch),
|
||||
);
|
||||
}
|
||||
|
||||
export function closeDocumentSelectionAfterCoachHandoff(
|
||||
history: Parameters<typeof closeDocumentSelectionHistoryEntry>[0],
|
||||
pathname: string,
|
||||
currentSearch: string,
|
||||
documentId: string,
|
||||
handedOffDocumentId: string | null,
|
||||
closeIntent: CoachCloseIntent | null | undefined,
|
||||
): string | null {
|
||||
if (handedOffDocumentId === documentId && closeIntent === "navigate") {
|
||||
return handedOffDocumentId;
|
||||
}
|
||||
|
||||
closeDocumentSelectionHistoryEntry(
|
||||
history,
|
||||
pathname,
|
||||
currentSearch,
|
||||
documentId,
|
||||
);
|
||||
return handedOffDocumentId === documentId ? null : handedOffDocumentId;
|
||||
}
|
||||
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
@@ -86,6 +183,7 @@ export function DocumentsView() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const coachLaunch = useCoachLaunch();
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
|
||||
@@ -540,42 +638,79 @@ export function DocumentsView() {
|
||||
// ── Detail sheet ──────────────────────────────────────────────────────
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const openDetail = useCallback((id: string) => {
|
||||
setDetailId(id);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
const handedOffDocumentRef = useRef<string | null>(null);
|
||||
|
||||
// `?doc=<id>` deep link (used by the illness page's document rows): open
|
||||
// the detail sheet once per value; closing the sheet strips the param so
|
||||
// back/forward and refresh behave. Render-phase derived-state adjustment
|
||||
// (the file's established pattern), consumed only once auth has resolved
|
||||
// so a deep link never burns before the module flag is known. The id is
|
||||
// shape-checked before it is interpolated into an API path.
|
||||
const docParam = searchParams.get("doc");
|
||||
const [consumedDocParam, setConsumedDocParam] = useState<string | null>(null);
|
||||
if (!authLoading && moduleEnabled && docParam !== consumedDocParam) {
|
||||
setConsumedDocParam(docParam);
|
||||
if (docParam !== null && /^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) {
|
||||
setDetailId(docParam);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
const rawDocParam = searchParams.get("doc");
|
||||
const docParam =
|
||||
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
|
||||
? rawDocParam
|
||||
: null;
|
||||
const [observedDocParam, setObservedDocParam] = useState<string | null>(null);
|
||||
|
||||
// The query is the navigation owner: direct links, browser back, and browser
|
||||
// forward all drive the same detail selection without remounting the route.
|
||||
// Wait for auth so a deep link cannot be consumed before the module gate is
|
||||
// known, and never interpolate an unvalidated id into the detail API path.
|
||||
if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) {
|
||||
setObservedDocParam(rawDocParam);
|
||||
setDetailId(docParam);
|
||||
setDetailOpen(docParam !== null);
|
||||
}
|
||||
|
||||
const handleDetailOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setDetailOpen(open);
|
||||
if (!open && searchParams.get("doc")) {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.delete("doc");
|
||||
const search = sp.toString();
|
||||
router.replace(search ? `${pathname}?${search}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
const openDetail = useCallback(
|
||||
(id: string) => {
|
||||
const href = documentSelectionHref(pathname, searchParams.toString(), id);
|
||||
if (href === null) return;
|
||||
handedOffDocumentRef.current = null;
|
||||
setDetailId(id);
|
||||
setDetailOpen(true);
|
||||
window.history.pushState(documentSelectionHistoryState(id), "", href);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
const handleDetailOpenChange = useCallback((open: boolean) => {
|
||||
setDetailOpen(open);
|
||||
}, []);
|
||||
|
||||
const coachOwnsDocument =
|
||||
docParam !== null &&
|
||||
coachLaunch?.open === true &&
|
||||
coachLaunch.documentId === docParam;
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !moduleEnabled) return;
|
||||
if (docParam === null) {
|
||||
handedOffDocumentRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (detailOpen) return;
|
||||
if (coachOwnsDocument) {
|
||||
// "Ask Coach" intentionally closes the sheet while the drawer owns the
|
||||
// same document. Keep the return URL intact so maximize → browser-back
|
||||
// can reconstruct the sheet from `?doc=`.
|
||||
handedOffDocumentRef.current = docParam;
|
||||
return;
|
||||
}
|
||||
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
|
||||
window.history,
|
||||
pathname,
|
||||
searchParams.toString(),
|
||||
docParam,
|
||||
handedOffDocumentRef.current,
|
||||
coachLaunch?.closeIntent,
|
||||
);
|
||||
}, [
|
||||
authLoading,
|
||||
coachOwnsDocument,
|
||||
coachLaunch?.closeIntent,
|
||||
detailOpen,
|
||||
docParam,
|
||||
moduleEnabled,
|
||||
pathname,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Hover/focus intent prefetches the detail METADATA (never the blob —
|
||||
// the blob fetch starts when the sheet mounts its preview element).
|
||||
const prefetchDetail = useCallback(
|
||||
|
||||
Reference in new issue
Block a user