feat(labs): add biomarker filtering and sorting

This commit is contained in:
Konrad Garbal committed 2026-11-10 20:46:57 +01:00
1 parent 22b85ff97d
commit b4ac67ee7c
36 files changed
+3290 -172

No files matched your search

@@ -109,6 +109,17 @@ function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
options={SOURCES}
/>
</FilterBar>
<button
type="button"
onClick={() =>
patchFilters(
{ type: "PULSE" },
{ mode: "replace", afterPaint: true },
)
}
>
Defer replace type
</button>
</>
);
}
@@ -193,6 +204,25 @@ describe("useUrlFilterSync with FilterBar", () => {
expect(window.history.length).toBe(entries);
});
it("defers writing until after paint and replaces current entry without adding history entries", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
(
screen.getByRole("button", { name: "Defer replace type" }).element() as HTMLButtonElement
).click();
// Immediately after trigger, the write is deferred until after paint:
// search remains unchanged and no history write has executed yet.
expect(history.writes).toEqual([]);
expect(currentSearch()).toBe("?type=WEIGHT");
// Once painted frames flush, the replace lands without extra history entry:
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual(["?type=PULSE"]);
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
+25 -6
View File
@@ -32,6 +32,13 @@ import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
* for a list embedded under a URL that belongs to another surface (the
* type-locked insights readings subpage).
*/
export interface UrlFilterSyncOptions {
/** A new history entry (`push`) or the current one rewritten (`replace`). */
mode?: "push" | "replace";
/** Wait two painted frames before writing (a write made while mounting). */
afterPaint?: boolean;
}
export function useUrlFilterSync<T>({
parse,
serialise,
@@ -44,10 +51,13 @@ export function useUrlFilterSync<T>({
filters: T;
applyFilters: (
next: T | ((current: T) => T),
mode?: "push" | "replace",
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
/** Merge `patch` into the live filters (the common facet change). */
patchFilters: (patch: Partial<T>, mode?: "push" | "replace") => void;
patchFilters: (
patch: Partial<T>,
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
} {
const params = useUrlSearch();
@@ -60,7 +70,14 @@ export function useUrlFilterSync<T>({
);
const applyFilters = useCallback(
(next: T | ((current: T) => T), mode: "push" | "replace" = "push") => {
(
next: T | ((current: T) => T),
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
const options: UrlFilterSyncOptions =
typeof modeOrOptions === "string"
? { mode: modeOrOptions }
: modeOrOptions;
const resolve = (current: T): T =>
typeof next === "function" ? (next as (c: T) => T)(current) : next;
if (!syncToUrl) {
@@ -72,15 +89,17 @@ export function useUrlFilterSync<T>({
const search = serialise(resolve(parse(live)));
setAllParams(search)(live);
},
{ mode },
{ mode: options.mode ?? "push", afterPaint: options.afterPaint },
);
},
[syncToUrl, serialise, parse],
);
const patchFilters = useCallback(
(patch: Partial<T>, mode: "push" | "replace" = "push") =>
applyFilters((current) => ({ ...current, ...patch }), mode),
(
patch: Partial<T>,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => applyFilters((current) => ({ ...current, ...patch }), modeOrOptions),
[applyFilters],
);