From c4be7dd1be75ba4301e48fe49bc10c5a8a70e32c Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 5 Mar 2026 13:14:33 +0100 Subject: [PATCH] refactor(frontend): align lab results filters with products style --- docs/frontend-design-cookbook.md | 5 +- frontend/src/app.css | 29 +- .../routes/health/lab-results/+page.svelte | 606 ++++++++---------- 3 files changed, 298 insertions(+), 342 deletions(-) diff --git a/docs/frontend-design-cookbook.md b/docs/frontend-design-cookbook.md index 0e9ccd4..d1cc169 100644 --- a/docs/frontend-design-cookbook.md +++ b/docs/frontend-design-cookbook.md @@ -102,7 +102,7 @@ These classes are already in use and should be reused: - Health semantic pills: `health-kind-pill*`, `health-flag-pill*` - Lab results utilities: - metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill` - - filter surfaces: `lab-results-filter-panel`, `lab-results-filter-banner`, `lab-results-pager` + - filter/paging surfaces: `editorial-filter-row`, `lab-results-filter-banner`, `lab-results-pager` - row/link rhythm: `lab-results-row`, `lab-results-code-link`, `lab-results-value-cell` - mobile density: `lab-results-mobile-grid`, `lab-results-mobile-card`, `lab-results-mobile-value` @@ -112,12 +112,13 @@ These classes are already in use and should be reused: - Validation/error states should be explicit and never color-only. - Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata. - Filter toolbars for data-heavy routes should use `GET` forms with URL params so state is shareable and pagination links preserve active filters. +- Use the products filter pattern as the shared baseline: compact search input, chip-style toggle rows (`editorial-filter-row` + small `Button` variants), and apply/reset actions aligned at the end of the toolbar. - For high-volume medical data lists, default the primary view to condensed/latest mode and offer full-history as an explicit secondary option. - In condensed/latest mode, group rows by collection date using lightweight section headers (`products-section-title`) to preserve report context without introducing heavy card nesting. - Change/highlight pills in dense tables should stay compact (`text-[10px]`), semantic (new/flag change/abnormal), and avoid overwhelming color blocks. - For lab results, keep ordering fixed to newest collection date (`collected_at DESC`) and remove non-essential controls (no lab filter and no manual sort selector). - For lab results, keep code links visibly interactive (`lab-results-code-link`) because they are a primary in-context drill-down interaction. -- For lab results, use compact metadata chips in hero sections (`lab-results-meta-pill`) for active view/filter context instead of introducing a second heavy summary card. +- For lab results, use compact metadata chips in hero sections (`lab-results-meta-pill`) for active view/filter context instead of introducing a second heavy summary card; keep this strip terse (one context chip + one stats chip, with optional alert chip). - In dense row-based lists, prefer `ghost` action controls; use icon-only buttons on desktop tables and short text+icon `ghost` actions on mobile cards to keep row actions subordinate to data. - For editable data tables, open a dedicated inline edit panel above the list (instead of per-row expanded forms) and prefill it from row actions; keep users on the same filtered/paginated context after save. - When a list is narrowed to a single entity key (for example `test_code`), display an explicit "filtered by" banner with a one-click clear action and avoid extra grouping wrappers that add no context. diff --git a/frontend/src/app.css b/frontend/src/app.css index e602017..fe2a6c1 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -392,21 +392,22 @@ body { } .lab-results-meta-strip { - margin-top: 0.9rem; + margin-top: 0.65rem; display: flex; flex-wrap: wrap; - gap: 0.45rem; + gap: 0.35rem; + max-width: 48ch; } .lab-results-meta-pill { border: 1px solid hsl(36 22% 74% / 0.9); border-radius: 999px; background: hsl(44 32% 93%); - padding: 0.2rem 0.62rem; + padding: 0.14rem 0.56rem; color: var(--editorial-muted); - font-size: 0.73rem; + font-size: 0.69rem; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.07em; text-transform: uppercase; } @@ -418,13 +419,6 @@ body { justify-content: center; } -.lab-results-filter-panel { - border-color: color-mix(in srgb, var(--page-accent) 24%, var(--border)); - background: - linear-gradient(180deg, hsl(44 36% 96%), hsl(42 29% 93%)), - repeating-linear-gradient(90deg, hsl(0 0% 100% / 0), hsl(0 0% 100% / 0) 18px, hsl(36 24% 70% / 0.1) 18px, hsl(36 24% 70% / 0.1) 19px); -} - .lab-results-filter-banner { border-style: dashed; border-color: color-mix(in srgb, var(--page-accent) 48%, var(--border)); @@ -945,11 +939,11 @@ body { } .lab-results-meta-strip { - margin-top: 0.75rem; + margin-top: 0.6rem; } .lab-results-meta-pill { - letter-spacing: 0.06em; + letter-spacing: 0.05em; } .lab-results-filter-banner { @@ -958,6 +952,13 @@ body { } } +@media (min-width: 768px) { + .lab-results-meta-strip { + grid-column: 1 / 2; + align-items: center; + } +} + @media (prefers-reduced-motion: reduce) { .reveal-1, .reveal-2, diff --git a/frontend/src/routes/health/lab-results/+page.svelte b/frontend/src/routes/health/lab-results/+page.svelte index fb72b5a..fe3218e 100644 --- a/frontend/src/routes/health/lab-results/+page.svelte +++ b/frontend/src/routes/health/lab-results/+page.svelte @@ -5,7 +5,7 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import { baseSelectClass } from '$lib/components/forms/form-classes'; + import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import { Pencil, X } from 'lucide-svelte'; @@ -95,48 +95,57 @@ type LabResultItem = PageData['resultPage']['items'][number]; type GroupedByDate = { date: string; items: LabResultItem[] }; + type DisplayGroup = { key: string; label: string | null; items: LabResultItem[] }; + type QueryOptions = { + page?: number; + testCode?: string; + includeExistingTestCode?: boolean; + forceLatestOnly?: 'true' | 'false'; + }; - function buildPageUrl(page: number) { - const base = '/health/lab-results'; + function toQueryString(params: Array<[string, string]>): string { + return params + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + } + + function buildQueryParams(options: QueryOptions = {}): Array<[string, string]> { const params: Array<[string, string]> = []; if (data.q) params.push(['q', data.q]); - if (data.test_code) params.push(['test_code', data.test_code]); + if (options.testCode) { + params.push(['test_code', options.testCode]); + } else if (options.includeExistingTestCode && data.test_code) { + params.push(['test_code', data.test_code]); + } if (data.flag) params.push(['flag', data.flag]); if (data.from_date) params.push(['from_date', data.from_date]); if (data.to_date) params.push(['to_date', data.to_date]); - if (!data.latestOnly) params.push(['latest_only', 'false']); - if (page > 1) params.push(['page', String(page)]); - const qs = params - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&'); - return qs ? `${base}?${qs}` : base; + + if (options.forceLatestOnly) { + params.push(['latest_only', options.forceLatestOnly]); + } else if (!data.latestOnly) { + params.push(['latest_only', 'false']); + } + + if (options.page && options.page > 1) params.push(['page', String(options.page)]); + return params; + } + + function buildLabResultsUrl(options: QueryOptions = {}): string { + const qs = toQueryString(buildQueryParams(options)); + return qs ? `/health/lab-results?${qs}` : '/health/lab-results'; + } + + function buildPageUrl(page: number) { + return buildLabResultsUrl({ page, includeExistingTestCode: true }); } function filterByCode(code: string) { - const params: Array<[string, string]> = []; - if (data.q) params.push(['q', data.q]); - params.push(['test_code', code]); - if (data.flag) params.push(['flag', data.flag]); - if (data.from_date) params.push(['from_date', data.from_date]); - if (data.to_date) params.push(['to_date', data.to_date]); - params.push(['latest_only', 'false']); - const qs = params - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&'); - window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results'; + window.location.href = buildLabResultsUrl({ testCode: code, forceLatestOnly: 'false' }); } function clearCodeFilterOnly() { - const params: Array<[string, string]> = []; - if (data.q) params.push(['q', data.q]); - if (data.flag) params.push(['flag', data.flag]); - if (data.from_date) params.push(['from_date', data.from_date]); - if (data.to_date) params.push(['to_date', data.to_date]); - if (!data.latestOnly) params.push(['latest_only', 'false']); - const qs = params - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&'); - window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results'; + window.location.href = buildLabResultsUrl(); } const groupedByDate = $derived.by(() => { @@ -152,7 +161,12 @@ return Object.entries(groups).map(([date, items]) => ({ date, items })); }); - const shouldGroupByDate = $derived(!data.test_code); + const displayGroups = $derived.by(() => { + if (data.test_code) { + return [{ key: 'filtered', label: null, items: data.resultPage.items }]; + } + return groupedByDate.map((group) => ({ key: group.date, label: group.date, items: group.items })); + }); const notableFlags = new Set(['ABN', 'H', 'L', 'POS']); const flaggedCount = $derived.by(() => @@ -172,6 +186,11 @@ } const flagOptions = flags.map((f) => ({ value: f, label: f })); + const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`; + let filterFlagOverride = $state(null); + let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null); + const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? '')); + const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false')); {m["labResults_title"]()} — innercontext @@ -184,8 +203,10 @@
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()} + {#if data.flag} + · {m['labResults_flagFilter']()} {data.flag} + {/if} - {m['labResults_flagFilter']()} {data.flag || m['labResults_flagAll']()} {m['labResults_flag']()}: {flaggedCount} @@ -305,7 +326,13 @@
- +
@@ -313,7 +340,13 @@
- +
@@ -325,43 +358,87 @@ {/if} -
-
- - + +
+
+ +
+
+ + +
-
- - -
-
- - -
-
- - -
-
- - -
-
- {#if data.test_code} - - {/if} - - + {#each flags as f (f)} + + {/each} +
+ +
+
+ + +
+
+ + + {#if data.test_code} + + {/if} + + +
@@ -452,6 +529,125 @@
{/if} + {#snippet desktopActions(r: LabResultItem)} +
+ +
{ + if (!confirm(m['labResults_confirmDelete']())) event.preventDefault(); + }} + > + + +
+
+ {/snippet} + + {#snippet mobileActions(r: LabResultItem)} +
+ +
{ + if (!confirm(m['labResults_confirmDelete']())) event.preventDefault(); + }} + > + + +
+
+ {/snippet} + + {#snippet desktopRow(r: LabResultItem)} + + {r.collected_at.slice(0, 10)} + + + + + + + {formatValue(r)} + + {#if r.flag} + {r.flag} + {:else} + — + {/if} + + {r.lab ?? '—'} + + {@render desktopActions(r)} + + + {/snippet} + + {#snippet mobileCard(r: LabResultItem)} +
+
+
+ +
+ {#if r.flag} + {r.flag} + {/if} +
+

{r.collected_at.slice(0, 10)}

+
+ + {formatValue(r)} +
+ {#if r.lab} +

{r.lab}

+ {/if} + {@render mobileActions(r)} +
+ {/snippet} +