feat(frontend): improve lab result filter ergonomics

This commit is contained in:
Piotr Oleszczyk 2026-03-10 12:44:19 +01:00
parent ed547703ad
commit 157cbc425e
7 changed files with 221 additions and 48 deletions

View file

@ -265,6 +265,7 @@ def list_lab_results(
test_code: Optional[str] = None,
flag: Optional[ResultFlag] = None,
flags: list[ResultFlag] = Query(default_factory=list),
without_flag: bool = False,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
latest_only: bool = False,
@ -287,6 +288,8 @@ def list_lab_results(
filters.append(LabResult.flag == flag)
if flags:
filters.append(col(LabResult.flag).in_(flags))
if without_flag:
filters.append(col(LabResult.flag).is_(None))
if from_date is not None:
filters.append(LabResult.collected_at >= from_date)
if to_date is not None:

View file

@ -367,6 +367,16 @@
"labResults_flagFilter": "Flag:",
"labResults_flagAll": "All",
"labResults_flagNone": "None",
"labResults_statusAll": "All",
"labResults_statusAbnormal": "Abnormal",
"labResults_statusNormal": "Normal",
"labResults_statusUninterpreted": "No interpretation",
"labResults_activeFilters": "Active filters",
"labResults_activeFilterSearch": "Search: {value}",
"labResults_activeFilterCode": "Code: {value}",
"labResults_activeFilterFrom": "From: {value}",
"labResults_activeFilterTo": "To: {value}",
"labResults_activeFilterHistory": "Full history",
"labResults_date": "Date *",
"labResults_loincCode": "LOINC code *",
"labResults_loincExample": "e.g. 718-7",

View file

@ -387,6 +387,16 @@
"labResults_flagFilter": "Flaga:",
"labResults_flagAll": "Wszystkie",
"labResults_flagNone": "Brak",
"labResults_statusAll": "Wszystkie",
"labResults_statusAbnormal": "Nieprawidłowe",
"labResults_statusNormal": "Prawidłowe",
"labResults_statusUninterpreted": "Bez interpretacji",
"labResults_activeFilters": "Aktywne filtry",
"labResults_activeFilterSearch": "Szukaj: {value}",
"labResults_activeFilterCode": "Kod: {value}",
"labResults_activeFilterFrom": "Od: {value}",
"labResults_activeFilterTo": "Do: {value}",
"labResults_activeFilterHistory": "Pełna historia",
"labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *",
"labResults_loincExample": "np. 718-7",

View file

@ -413,6 +413,23 @@ body {
margin-bottom: 0.65rem;
}
.lab-results-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid color-mix(in srgb, var(--page-accent) 30%, var(--border));
border-radius: 999px;
background: color-mix(in srgb, var(--page-accent) 10%, white);
padding: 0.35rem 0.65rem;
color: var(--foreground);
font-size: 0.78rem;
text-decoration: none;
}
.lab-results-filter-chip:hover {
background: color-mix(in srgb, var(--page-accent) 16%, white);
}
.editorial-alert {
border-radius: 0.7rem;
border: 1px solid hsl(34 25% 75% / 0.8);

View file

@ -274,6 +274,8 @@ export interface LabResultListParams {
q?: string;
test_code?: string;
flag?: string;
flags?: string[];
without_flag?: boolean;
from_date?: string;
to_date?: string;
latest_only?: boolean;
@ -295,6 +297,10 @@ export function getLabResults(
if (params.q) q.set("q", params.q);
if (params.test_code) q.set("test_code", params.test_code);
if (params.flag) q.set("flag", params.flag);
if (params.flags?.length) {
for (const flag of params.flags) q.append("flags", flag);
}
if (params.without_flag != null) q.set("without_flag", String(params.without_flag));
if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date);
if (params.latest_only != null) q.set("latest_only", String(params.latest_only));

View file

@ -2,10 +2,18 @@ import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
const STATUS_GROUP_FLAGS = {
abnormal: ['ABN', 'H', 'L', 'POS'],
normal: ['N', 'NEG']
} as const;
type StatusGroup = 'all' | 'abnormal' | 'normal' | 'uninterpreted';
export const load: PageServerLoad = async ({ url }) => {
const q = url.searchParams.get('q') ?? undefined;
const test_code = url.searchParams.get('test_code') ?? undefined;
const flag = url.searchParams.get('flag') ?? undefined;
const status_group = normalizeStatusGroup(url.searchParams.get('status_group'));
const from_date = url.searchParams.get('from_date') ?? undefined;
const to_date = url.searchParams.get('to_date') ?? undefined;
const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false';
@ -15,10 +23,19 @@ export const load: PageServerLoad = async ({ url }) => {
const limit = 50;
const offset = (page - 1) * limit;
const statusFlags = status_group === 'all' || status_group === 'uninterpreted'
? []
: [...STATUS_GROUP_FLAGS[status_group]];
const effectiveFlag = flag && statusFlags.includes(flag as (typeof statusFlags)[number]) ? flag : undefined;
const flags = effectiveFlag ? undefined : statusFlags;
const without_flag = status_group === 'uninterpreted' ? true : undefined;
const resultPage = await getLabResults({
q,
test_code,
flag,
flag: effectiveFlag,
flags,
without_flag,
from_date,
to_date,
latest_only: latestOnly,
@ -31,7 +48,8 @@ export const load: PageServerLoad = async ({ url }) => {
resultPage,
q,
test_code,
flag,
flag: effectiveFlag,
status_group,
from_date,
to_date,
latestOnly,
@ -40,6 +58,11 @@ export const load: PageServerLoad = async ({ url }) => {
};
};
function normalizeStatusGroup(value: string | null): StatusGroup {
if (value === 'abnormal' || value === 'normal' || value === 'uninterpreted') return value;
return 'all';
}
export const actions: Actions = {
update: async ({ request }) => {
const form = await request.formData();

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@ -23,6 +24,8 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
const statusGroups = ['all', 'abnormal', 'normal', 'uninterpreted'] as const;
type StatusGroup = (typeof statusGroups)[number];
const flagPills: Record<string, string> = {
N: 'health-flag-pill health-flag-pill--normal',
ABN: 'health-flag-pill health-flag-pill--abnormal',
@ -100,8 +103,39 @@
page?: number;
testCode?: string;
includeExistingTestCode?: boolean;
forceLatestOnly?: 'true' | 'false';
latestOnly?: 'true' | 'false';
statusGroup?: StatusGroup;
flag?: string;
clearKeys?: Array<'q' | 'test_code' | 'flag' | 'from_date' | 'to_date' | 'latest_only' | 'status_group'>;
};
const statusGroupLabels: Record<StatusGroup, () => string> = {
all: m['labResults_statusAll'],
abnormal: m['labResults_statusAbnormal'],
normal: m['labResults_statusNormal'],
uninterpreted: m['labResults_statusUninterpreted']
};
const statusGroupFlags: Record<Exclude<StatusGroup, 'all' | 'uninterpreted'>, string[]> = {
abnormal: ['ABN', 'H', 'L', 'POS'],
normal: ['N', 'NEG']
};
function matchesStatusGroup(flag: string | undefined, statusGroup: StatusGroup): boolean {
if (statusGroup === 'all') return true;
if (statusGroup === 'uninterpreted') return !flag;
if (!flag) return false;
return statusGroupFlags[statusGroup].includes(flag);
}
function normalizeStatusGroup(statusGroup?: string | null): StatusGroup {
if (statusGroup === 'abnormal' || statusGroup === 'normal' || statusGroup === 'uninterpreted') return statusGroup;
return 'all';
}
function inferStatusGroupForFlag(flag: string): StatusGroup {
if (statusGroupFlags.abnormal.includes(flag)) return 'abnormal';
if (statusGroupFlags.normal.includes(flag)) return 'normal';
return 'all';
}
function toQueryString(params: Array<[string, string]>): string {
return params
@ -110,20 +144,25 @@
}
function buildQueryParams(options: QueryOptions = {}): Array<[string, string]> {
const clearKeys = new Set(options.clearKeys ?? []);
const params: Array<[string, string]> = [];
if (data.q) params.push(['q', data.q]);
if (data.q && !clearKeys.has('q')) params.push(['q', data.q]);
if (options.testCode) {
params.push(['test_code', options.testCode]);
} else if (options.includeExistingTestCode && data.test_code) {
} else if (options.includeExistingTestCode && data.test_code && !clearKeys.has('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 (options.forceLatestOnly) {
params.push(['latest_only', options.forceLatestOnly]);
} else if (!data.latestOnly) {
const nextStatusGroup = options.statusGroup ?? (clearKeys.has('status_group') ? 'all' : normalizeStatusGroup(data.status_group));
const nextFlag = options.flag !== undefined ? options.flag : clearKeys.has('flag') ? '' : data.flag ?? '';
if (nextStatusGroup !== 'all') params.push(['status_group', nextStatusGroup]);
if (nextFlag) params.push(['flag', nextFlag]);
if (data.from_date && !clearKeys.has('from_date')) params.push(['from_date', data.from_date]);
if (data.to_date && !clearKeys.has('to_date')) params.push(['to_date', data.to_date]);
const nextLatestOnly = options.latestOnly ?? (clearKeys.has('latest_only') ? 'true' : data.latestOnly ? 'true' : 'false');
if (nextLatestOnly === 'false') {
params.push(['latest_only', 'false']);
}
@ -136,16 +175,12 @@
return qs ? `/health/lab-results?${qs}` : '/health/lab-results';
}
function buildPageUrl(page: number) {
return buildLabResultsUrl({ page, includeExistingTestCode: true });
async function goToFilters(options: QueryOptions = {}) {
await goto(buildLabResultsUrl(options));
}
function filterByCode(code: string) {
window.location.href = buildLabResultsUrl({ testCode: code, forceLatestOnly: 'false' });
}
function clearCodeFilterOnly() {
window.location.href = buildLabResultsUrl();
async function filterByCode(code: string) {
await goToFilters({ testCode: code, latestOnly: 'false' });
}
const groupedByDate = $derived.by<GroupedByDate[]>(() => {
@ -162,9 +197,6 @@
});
const displayGroups = $derived.by<DisplayGroup[]>(() => {
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 }));
});
@ -186,10 +218,61 @@
}
const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`;
let filterFlagOverride = $state<string | null>(null);
let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null);
const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? ''));
const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false'));
const activeStatusGroup = $derived(normalizeStatusGroup(data.status_group));
const activeFilterFlag = $derived(data.flag ?? '');
const activeLatestOnly = $derived(data.latestOnly ? 'true' : 'false');
const visibleFlags = $derived.by(() => {
if (activeStatusGroup === 'abnormal') return statusGroupFlags.abnormal;
if (activeStatusGroup === 'normal') return statusGroupFlags.normal;
if (activeStatusGroup === 'uninterpreted') return [];
return flags;
});
const activeFilterChips = $derived.by(() => {
const chips: Array<{ label: string; href: string }> = [];
if (activeStatusGroup !== 'all') {
chips.push({
label: statusGroupLabels[activeStatusGroup](),
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['status_group', 'flag'] })
});
}
if (activeFilterFlag) {
chips.push({
label: `${m['labResults_flag']()}: ${activeFilterFlag}`,
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['flag'] })
});
}
if (data.test_code) {
chips.push({
label: m['labResults_activeFilterCode']({ value: data.test_code }),
href: buildLabResultsUrl({ clearKeys: ['test_code'] })
});
}
if (data.q) {
chips.push({
label: m['labResults_activeFilterSearch']({ value: data.q }),
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['q'] })
});
}
if (data.from_date) {
chips.push({
label: m['labResults_activeFilterFrom']({ value: data.from_date.slice(0, 10) }),
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['from_date'] })
});
}
if (data.to_date) {
chips.push({
label: m['labResults_activeFilterTo']({ value: data.to_date.slice(0, 10) }),
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['to_date'] })
});
}
if (!data.latestOnly) {
chips.push({
label: m['labResults_activeFilterHistory'](),
href: buildLabResultsUrl({ includeExistingTestCode: true, latestOnly: 'true' })
});
}
return chips;
});
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
...(form?.deleted ? [{ kind: 'success' as const, text: m["labResults_deleted"]() }] : []),
@ -370,36 +453,53 @@
type="button"
size="sm"
variant={activeLatestOnly === 'true' ? 'default' : 'outline'}
onclick={() => (filterLatestOnlyOverride = 'true')}
onclick={() => goToFilters({ includeExistingTestCode: true, latestOnly: 'true' })}
>
{m["labResults_viewLatest"]()}
</Button>
<Button
<Button
type="button"
size="sm"
variant={activeLatestOnly === 'false' ? 'default' : 'outline'}
onclick={() => (filterLatestOnlyOverride = 'false')}
onclick={() => goToFilters({ includeExistingTestCode: true, latestOnly: 'false' })}
>
{m["labResults_viewAll"]()}
</Button>
</div>
</div>
<div class="editorial-filter-row">
{#each statusGroups as statusGroup (statusGroup)}
<Button
type="button"
size="sm"
variant={activeStatusGroup === statusGroup ? 'default' : 'outline'}
onclick={() => {
const nextFlag = matchesStatusGroup(activeFilterFlag || undefined, statusGroup) ? activeFilterFlag : '';
goToFilters({ includeExistingTestCode: true, statusGroup, flag: nextFlag });
}}
>
{statusGroupLabels[statusGroup]()}
</Button>
{/each}
</div>
<div class="editorial-filter-row">
<Button
type="button"
size="sm"
variant={activeFilterFlag === '' ? 'default' : 'outline'}
onclick={() => (filterFlagOverride = '')}
onclick={() => goToFilters({ includeExistingTestCode: true, flag: '' })}
disabled={activeStatusGroup === 'uninterpreted'}
>
{m["labResults_flagAll"]()}
</Button>
{#each flags as f (f)}
{#each visibleFlags as f (f)}
<Button
type="button"
size="sm"
variant={activeFilterFlag === f ? 'default' : 'outline'}
onclick={() => (filterFlagOverride = f)}
onclick={() => goToFilters({ includeExistingTestCode: true, statusGroup: inferStatusGroupForFlag(f), flag: f })}
>
{f}
</Button>
@ -424,31 +524,35 @@
/>
</div>
<div class="flex items-center gap-2">
<input type="hidden" name="flag" value={activeFilterFlag} />
{#if activeFilterFlag}
<input type="hidden" name="flag" value={activeFilterFlag} />
{/if}
{#if activeStatusGroup !== 'all'}
<input type="hidden" name="status_group" value={activeStatusGroup} />
{/if}
<input type="hidden" name="latest_only" value={activeLatestOnly} />
{#if data.test_code}
<input type="hidden" name="test_code" value={data.test_code} />
{/if}
<Button type="submit">{m["labResults_applyFilters"]()}</Button>
<Button type="button" variant="outline" onclick={() => (window.location.href = '/health/lab-results')}>
<Button type="button" variant="outline" onclick={() => goToFilters({ clearKeys: ['q', 'test_code', 'flag', 'from_date', 'to_date', 'latest_only', 'status_group'] })}>
{m["labResults_resetAllFilters"]()}
</Button>
</div>
</div>
</form>
{#if data.test_code}
<div class="editorial-panel lab-results-filter-banner reveal-2 flex items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
{m['labResults_filteredByCode']({ code: data.test_code })}
</p>
<Button
type="button"
variant="outline"
onclick={clearCodeFilterOnly}
>
{m['labResults_clearCodeFilter']()}
</Button>
{#if activeFilterChips.length}
<div class="editorial-panel reveal-2 space-y-3">
<p class="text-sm font-medium">{m['labResults_activeFilters']()}</p>
<div class="flex flex-wrap gap-2">
{#each activeFilterChips as chip (chip.label)}
<a class="lab-results-filter-chip" href={chip.href}>
<span>{chip.label}</span>
<X class="size-3.5" />
</a>
{/each}
</div>
</div>
{/if}
@ -457,7 +561,7 @@
<Button
variant="outline"
disabled={data.page <= 1}
onclick={() => (window.location.href = buildPageUrl(data.page - 1))}
onclick={() => goToFilters({ includeExistingTestCode: true, page: data.page - 1 })}
>
{m["labResults_previous"]()}
</Button>
@ -467,7 +571,7 @@
<Button
variant="outline"
disabled={data.page >= data.totalPages}
onclick={() => (window.location.href = buildPageUrl(data.page + 1))}
onclick={() => goToFilters({ includeExistingTestCode: true, page: data.page + 1 })}
>
{m["labResults_next"]()}
</Button>