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

View file

@ -367,6 +367,16 @@
"labResults_flagFilter": "Flag:", "labResults_flagFilter": "Flag:",
"labResults_flagAll": "All", "labResults_flagAll": "All",
"labResults_flagNone": "None", "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_date": "Date *",
"labResults_loincCode": "LOINC code *", "labResults_loincCode": "LOINC code *",
"labResults_loincExample": "e.g. 718-7", "labResults_loincExample": "e.g. 718-7",

View file

@ -387,6 +387,16 @@
"labResults_flagFilter": "Flaga:", "labResults_flagFilter": "Flaga:",
"labResults_flagAll": "Wszystkie", "labResults_flagAll": "Wszystkie",
"labResults_flagNone": "Brak", "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_date": "Data *",
"labResults_loincCode": "Kod LOINC *", "labResults_loincCode": "Kod LOINC *",
"labResults_loincExample": "np. 718-7", "labResults_loincExample": "np. 718-7",

View file

@ -413,6 +413,23 @@ body {
margin-bottom: 0.65rem; 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 { .editorial-alert {
border-radius: 0.7rem; border-radius: 0.7rem;
border: 1px solid hsl(34 25% 75% / 0.8); border: 1px solid hsl(34 25% 75% / 0.8);

View file

@ -274,6 +274,8 @@ export interface LabResultListParams {
q?: string; q?: string;
test_code?: string; test_code?: string;
flag?: string; flag?: string;
flags?: string[];
without_flag?: boolean;
from_date?: string; from_date?: string;
to_date?: string; to_date?: string;
latest_only?: boolean; latest_only?: boolean;
@ -295,6 +297,10 @@ export function getLabResults(
if (params.q) q.set("q", params.q); if (params.q) q.set("q", params.q);
if (params.test_code) q.set("test_code", params.test_code); if (params.test_code) q.set("test_code", params.test_code);
if (params.flag) q.set("flag", params.flag); 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.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_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)); 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 { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; 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 }) => { export const load: PageServerLoad = async ({ url }) => {
const q = url.searchParams.get('q') ?? undefined; const q = url.searchParams.get('q') ?? undefined;
const test_code = url.searchParams.get('test_code') ?? undefined; const test_code = url.searchParams.get('test_code') ?? undefined;
const flag = url.searchParams.get('flag') ?? 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 from_date = url.searchParams.get('from_date') ?? undefined;
const to_date = url.searchParams.get('to_date') ?? undefined; const to_date = url.searchParams.get('to_date') ?? undefined;
const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false'; const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false';
@ -15,10 +23,19 @@ export const load: PageServerLoad = async ({ url }) => {
const limit = 50; const limit = 50;
const offset = (page - 1) * limit; 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({ const resultPage = await getLabResults({
q, q,
test_code, test_code,
flag, flag: effectiveFlag,
flags,
without_flag,
from_date, from_date,
to_date, to_date,
latest_only: latestOnly, latest_only: latestOnly,
@ -31,7 +48,8 @@ export const load: PageServerLoad = async ({ url }) => {
resultPage, resultPage,
q, q,
test_code, test_code,
flag, flag: effectiveFlag,
status_group,
from_date, from_date,
to_date, to_date,
latestOnly, 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 = { export const actions: Actions = {
update: async ({ request }) => { update: async ({ request }) => {
const form = await request.formData(); const form = await request.formData();

View file

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